Разбить модули Deal.Modules.* по назначению
Application проектов Discovery, Kanban, Pipeline, Settings, Tenants разделён на Abstractions/Exceptions/Extensions/Models/Registrars/Services; namespace приведён к путям, using потребителей мигрированы и дедуплицированы (169 файлов), cref/FQN обновлены.
This commit is contained in:
@@ -6,7 +6,7 @@ namespace Deal.Infrastructure.Persistence.Entities;
|
||||
/// <remarks>
|
||||
/// Единое KV-хранилище всего SaaS-контура (ТЗ §4.1/§8.1): значения задаёт оператор, видят все
|
||||
/// тенанты. Секреты хранятся зашифрованными (префикс <c>enc:</c>), формат значения определяет ключ
|
||||
/// (<see cref="Deal.Modules.Settings.Application.GlobalSettingsKeys"/>).
|
||||
/// (<see cref="Deal.Modules.Settings.Application.Models.GlobalSettingsKeys"/>).
|
||||
/// </remarks>
|
||||
public sealed class GlobalSettingEntity
|
||||
{
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Deal.Modules.Tenants.Application;
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Extensions;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using Deal.Modules.Tenants.Application.Registrars;
|
||||
using Deal.Modules.Tenants.Application.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Repositories;
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Deal.Modules.Tenants.Application;
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Extensions;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using Deal.Modules.Tenants.Application.Registrars;
|
||||
using Deal.Modules.Tenants.Application.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Repositories;
|
||||
|
||||
@@ -1,65 +1,69 @@
|
||||
using System.Text.Json;
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Deal.Modules.Discovery.Application;
|
||||
using Deal.Modules.Discovery.Application.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Чёрный список Discovery — partial-часть <see cref="DiscoveryStore"/> (C32: выделено из общего
|
||||
/// файла, поведение не менялось): upsert/удаление/чтение/список DiscBlacklist (ON CONFLICT DO UPDATE).
|
||||
/// </summary>
|
||||
public sealed partial class DiscoveryStore
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task UpsertBlacklistAsync(string dialogId, string name, string reason, CancellationToken ct)
|
||||
{
|
||||
DiscBlacklistEntity? row = await _dbContext.DiscBlacklist
|
||||
.FirstOrDefaultAsync(entry => entry.DialogId == dialogId, ct);
|
||||
if (row is null)
|
||||
{
|
||||
_dbContext.DiscBlacklist.Add(new DiscBlacklistEntity
|
||||
{
|
||||
DialogId = dialogId,
|
||||
Name = name,
|
||||
Reason = reason,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
// add_blacklist L572–575 (ON CONFLICT DO UPDATE): name/reason обновляются, CreatedAt сохраняется.
|
||||
row.Name = name;
|
||||
row.Reason = reason;
|
||||
}
|
||||
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task RemoveBlacklistAsync(string dialogId, CancellationToken ct)
|
||||
{
|
||||
await _dbContext.DiscBlacklist.Where(entry => entry.DialogId == dialogId).ExecuteDeleteAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DiscoveryBlacklistDto?> GetBlacklistAsync(string dialogId, CancellationToken ct)
|
||||
{
|
||||
DiscBlacklistEntity? row = await _dbContext.DiscBlacklist
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(entry => entry.DialogId == dialogId, ct);
|
||||
return row is null ? null : ToBlacklistDto(row);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<DiscoveryBlacklistDto>> ListBlacklistAsync(CancellationToken ct)
|
||||
{
|
||||
List<DiscBlacklistEntity> rows = await _dbContext.DiscBlacklist
|
||||
.AsNoTracking()
|
||||
.OrderByDescending(entry => entry.CreatedAt)
|
||||
.ToListAsync(ct);
|
||||
return rows.Select(ToBlacklistDto).ToList();
|
||||
}
|
||||
|
||||
}
|
||||
using System.Text.Json;
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Deal.Modules.Discovery.Application.Abstractions;
|
||||
using Deal.Modules.Discovery.Application.Exceptions;
|
||||
using Deal.Modules.Discovery.Application.Extensions;
|
||||
using Deal.Modules.Discovery.Application.Models;
|
||||
using Deal.Modules.Discovery.Application.Registrars;
|
||||
using Deal.Modules.Discovery.Application.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Чёрный список Discovery — partial-часть <see cref="DiscoveryStore"/> (C32: выделено из общего
|
||||
/// файла, поведение не менялось): upsert/удаление/чтение/список DiscBlacklist (ON CONFLICT DO UPDATE).
|
||||
/// </summary>
|
||||
public sealed partial class DiscoveryStore
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task UpsertBlacklistAsync(string dialogId, string name, string reason, CancellationToken ct)
|
||||
{
|
||||
DiscBlacklistEntity? row = await _dbContext.DiscBlacklist
|
||||
.FirstOrDefaultAsync(entry => entry.DialogId == dialogId, ct);
|
||||
if (row is null)
|
||||
{
|
||||
_dbContext.DiscBlacklist.Add(new DiscBlacklistEntity
|
||||
{
|
||||
DialogId = dialogId,
|
||||
Name = name,
|
||||
Reason = reason,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
// add_blacklist L572–575 (ON CONFLICT DO UPDATE): name/reason обновляются, CreatedAt сохраняется.
|
||||
row.Name = name;
|
||||
row.Reason = reason;
|
||||
}
|
||||
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task RemoveBlacklistAsync(string dialogId, CancellationToken ct)
|
||||
{
|
||||
await _dbContext.DiscBlacklist.Where(entry => entry.DialogId == dialogId).ExecuteDeleteAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DiscoveryBlacklistDto?> GetBlacklistAsync(string dialogId, CancellationToken ct)
|
||||
{
|
||||
DiscBlacklistEntity? row = await _dbContext.DiscBlacklist
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(entry => entry.DialogId == dialogId, ct);
|
||||
return row is null ? null : ToBlacklistDto(row);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<DiscoveryBlacklistDto>> ListBlacklistAsync(CancellationToken ct)
|
||||
{
|
||||
List<DiscBlacklistEntity> rows = await _dbContext.DiscBlacklist
|
||||
.AsNoTracking()
|
||||
.OrderByDescending(entry => entry.CreatedAt)
|
||||
.ToListAsync(ct);
|
||||
return rows.Select(ToBlacklistDto).ToList();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+162
-158
@@ -1,158 +1,162 @@
|
||||
using System.Text.Json;
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Deal.Modules.Discovery.Application;
|
||||
using Deal.Modules.Discovery.Application.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Кандидаты Discovery — partial-часть <see cref="DiscoveryStore"/> (C32: выделено из общего файла,
|
||||
/// поведение не менялось): список/чтение кандидатов (DiscCandidates), мониторинг диалога и чёрный список,
|
||||
/// создание/патч/статусы joined/rejected и счётчик сбоев вступлений.
|
||||
/// </summary>
|
||||
public sealed partial class DiscoveryStore
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<DiscoveryCandidateDto>> ListCandidatesAsync(string taskId, string? status, CancellationToken ct)
|
||||
{
|
||||
IQueryable<DiscCandidateEntity> query = _dbContext.DiscCandidates.AsNoTracking().Where(candidate => candidate.TaskId == taskId);
|
||||
if (status is not null)
|
||||
{
|
||||
query = query.Where(candidate => candidate.Status == status);
|
||||
}
|
||||
|
||||
List<DiscCandidateEntity> rows = await query.OrderBy(candidate => candidate.CreatedAt).ToListAsync(ct);
|
||||
return rows.Select(ToCandidateDto).ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DiscoveryCandidateDto?> GetCandidateAsync(string dialogId, CancellationToken ct)
|
||||
{
|
||||
DiscCandidateEntity? row = await _dbContext.DiscCandidates
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct);
|
||||
return row is null ? null : ToCandidateDto(row);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> IsDialogMonitoredAsync(string dialogId, CancellationToken ct)
|
||||
{
|
||||
return await _dbContext.Dialogs.AnyAsync(dialog => dialog.Id == dialogId, ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> IsBlacklistedAsync(string dialogId, CancellationToken ct)
|
||||
{
|
||||
return _dbContext.DiscBlacklist.AnyAsync(row => row.DialogId == dialogId, ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task CreateCandidateAsync(DiscoveryCandidateRow row, CancellationToken ct)
|
||||
{
|
||||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||
_dbContext.DiscCandidates.Add(new DiscCandidateEntity
|
||||
{
|
||||
DialogId = row.DialogId,
|
||||
TaskId = row.TaskId,
|
||||
Name = row.Name,
|
||||
Username = row.Username,
|
||||
Kind = row.Kind,
|
||||
Hue = row.Hue,
|
||||
Status = "new",
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now,
|
||||
});
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteCandidateAsync(string dialogId, CancellationToken ct)
|
||||
{
|
||||
await _dbContext.DiscCandidates.Where(candidate => candidate.DialogId == dialogId).ExecuteDeleteAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> PatchCandidateAsync(string dialogId, DiscoveryCandidatePatch patch, CancellationToken ct)
|
||||
{
|
||||
DiscCandidateEntity? row = await _dbContext.DiscCandidates
|
||||
.FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct);
|
||||
if (row is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
ApplyCandidatePatch(row, patch);
|
||||
row.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> SetCandidateStatusAsync(string dialogId, string status, CancellationToken ct)
|
||||
{
|
||||
DiscCandidateEntity? row = await _dbContext.DiscCandidates
|
||||
.FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct);
|
||||
if (row is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
row.Status = status;
|
||||
row.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> SetCandidateJoinedAsync(string dialogId, bool autoJoined, CancellationToken ct)
|
||||
{
|
||||
DiscCandidateEntity? row = await _dbContext.DiscCandidates
|
||||
.FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct);
|
||||
if (row is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// mark_joined L531–534: status=joined + auto_joined.
|
||||
row.Status = "joined";
|
||||
row.AutoJoined = autoJoined;
|
||||
row.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<int?> IncrementJoinFailuresAsync(string dialogId, CancellationToken ct)
|
||||
{
|
||||
DiscCandidateEntity? row = await _dbContext.DiscCandidates
|
||||
.FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct);
|
||||
if (row is null || row.Status != "review")
|
||||
{
|
||||
// воркер L404–416: счётчик и удаление трогаем только у живой записи в статусе review.
|
||||
return null;
|
||||
}
|
||||
|
||||
row.JoinFailures += 1;
|
||||
row.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
return row.JoinFailures;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> SetCandidateRejectedAsync(string dialogId, CancellationToken ct)
|
||||
{
|
||||
DiscCandidateEntity? row = await _dbContext.DiscCandidates
|
||||
.FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct);
|
||||
if (row is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
row.Status = "rejected";
|
||||
row.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
}
|
||||
using System.Text.Json;
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Deal.Modules.Discovery.Application.Abstractions;
|
||||
using Deal.Modules.Discovery.Application.Exceptions;
|
||||
using Deal.Modules.Discovery.Application.Extensions;
|
||||
using Deal.Modules.Discovery.Application.Models;
|
||||
using Deal.Modules.Discovery.Application.Registrars;
|
||||
using Deal.Modules.Discovery.Application.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Кандидаты Discovery — partial-часть <see cref="DiscoveryStore"/> (C32: выделено из общего файла,
|
||||
/// поведение не менялось): список/чтение кандидатов (DiscCandidates), мониторинг диалога и чёрный список,
|
||||
/// создание/патч/статусы joined/rejected и счётчик сбоев вступлений.
|
||||
/// </summary>
|
||||
public sealed partial class DiscoveryStore
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<DiscoveryCandidateDto>> ListCandidatesAsync(string taskId, string? status, CancellationToken ct)
|
||||
{
|
||||
IQueryable<DiscCandidateEntity> query = _dbContext.DiscCandidates.AsNoTracking().Where(candidate => candidate.TaskId == taskId);
|
||||
if (status is not null)
|
||||
{
|
||||
query = query.Where(candidate => candidate.Status == status);
|
||||
}
|
||||
|
||||
List<DiscCandidateEntity> rows = await query.OrderBy(candidate => candidate.CreatedAt).ToListAsync(ct);
|
||||
return rows.Select(ToCandidateDto).ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DiscoveryCandidateDto?> GetCandidateAsync(string dialogId, CancellationToken ct)
|
||||
{
|
||||
DiscCandidateEntity? row = await _dbContext.DiscCandidates
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct);
|
||||
return row is null ? null : ToCandidateDto(row);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> IsDialogMonitoredAsync(string dialogId, CancellationToken ct)
|
||||
{
|
||||
return await _dbContext.Dialogs.AnyAsync(dialog => dialog.Id == dialogId, ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> IsBlacklistedAsync(string dialogId, CancellationToken ct)
|
||||
{
|
||||
return _dbContext.DiscBlacklist.AnyAsync(row => row.DialogId == dialogId, ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task CreateCandidateAsync(DiscoveryCandidateRow row, CancellationToken ct)
|
||||
{
|
||||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||
_dbContext.DiscCandidates.Add(new DiscCandidateEntity
|
||||
{
|
||||
DialogId = row.DialogId,
|
||||
TaskId = row.TaskId,
|
||||
Name = row.Name,
|
||||
Username = row.Username,
|
||||
Kind = row.Kind,
|
||||
Hue = row.Hue,
|
||||
Status = "new",
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now,
|
||||
});
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteCandidateAsync(string dialogId, CancellationToken ct)
|
||||
{
|
||||
await _dbContext.DiscCandidates.Where(candidate => candidate.DialogId == dialogId).ExecuteDeleteAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> PatchCandidateAsync(string dialogId, DiscoveryCandidatePatch patch, CancellationToken ct)
|
||||
{
|
||||
DiscCandidateEntity? row = await _dbContext.DiscCandidates
|
||||
.FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct);
|
||||
if (row is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
ApplyCandidatePatch(row, patch);
|
||||
row.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> SetCandidateStatusAsync(string dialogId, string status, CancellationToken ct)
|
||||
{
|
||||
DiscCandidateEntity? row = await _dbContext.DiscCandidates
|
||||
.FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct);
|
||||
if (row is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
row.Status = status;
|
||||
row.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> SetCandidateJoinedAsync(string dialogId, bool autoJoined, CancellationToken ct)
|
||||
{
|
||||
DiscCandidateEntity? row = await _dbContext.DiscCandidates
|
||||
.FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct);
|
||||
if (row is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// mark_joined L531–534: status=joined + auto_joined.
|
||||
row.Status = "joined";
|
||||
row.AutoJoined = autoJoined;
|
||||
row.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<int?> IncrementJoinFailuresAsync(string dialogId, CancellationToken ct)
|
||||
{
|
||||
DiscCandidateEntity? row = await _dbContext.DiscCandidates
|
||||
.FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct);
|
||||
if (row is null || row.Status != "review")
|
||||
{
|
||||
// воркер L404–416: счётчик и удаление трогаем только у живой записи в статусе review.
|
||||
return null;
|
||||
}
|
||||
|
||||
row.JoinFailures += 1;
|
||||
row.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
return row.JoinFailures;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> SetCandidateRejectedAsync(string dialogId, CancellationToken ct)
|
||||
{
|
||||
DiscCandidateEntity? row = await _dbContext.DiscCandidates
|
||||
.FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct);
|
||||
if (row is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
row.Status = "rejected";
|
||||
row.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,47 +1,51 @@
|
||||
using System.Text.Json;
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Deal.Modules.Discovery.Application;
|
||||
using Deal.Modules.Discovery.Application.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Лог Discovery — partial-часть <see cref="DiscoveryStore"/> (C32: выделено из общего файла,
|
||||
/// поведение не менялось): запись события, счётчик событий с начала суток (квота вступлений) и лог задачи.
|
||||
/// </summary>
|
||||
public sealed partial class DiscoveryStore
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task AddLogAsync(string logId, string taskId, string logEvent, string text, CancellationToken ct)
|
||||
{
|
||||
_dbContext.DiscLog.Add(new DiscLogEntity
|
||||
{
|
||||
Id = logId,
|
||||
TaskId = taskId,
|
||||
Event = logEvent,
|
||||
Text = text,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<int> CountLogEventAsync(string logEvent, DateTimeOffset sinceUtc, CancellationToken ct)
|
||||
{
|
||||
// ban_guard.joins_today_auto L29–35: число событий лога по типу с начала UTC-суток (счётчик квоты).
|
||||
return await _dbContext.DiscLog.CountAsync(row => row.Event == logEvent && row.CreatedAt >= sinceUtc, ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<DiscoveryLogDto>> ListTaskLogAsync(string taskId, int limit, CancellationToken ct)
|
||||
{
|
||||
List<DiscLogEntity> rows = await _dbContext.DiscLog
|
||||
.AsNoTracking()
|
||||
.Where(log => log.TaskId == taskId)
|
||||
.OrderByDescending(log => log.CreatedAt)
|
||||
.Take(limit)
|
||||
.ToListAsync(ct);
|
||||
return rows.Select(ToLogDto).ToList();
|
||||
}
|
||||
}
|
||||
using System.Text.Json;
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Deal.Modules.Discovery.Application.Abstractions;
|
||||
using Deal.Modules.Discovery.Application.Exceptions;
|
||||
using Deal.Modules.Discovery.Application.Extensions;
|
||||
using Deal.Modules.Discovery.Application.Models;
|
||||
using Deal.Modules.Discovery.Application.Registrars;
|
||||
using Deal.Modules.Discovery.Application.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Лог Discovery — partial-часть <see cref="DiscoveryStore"/> (C32: выделено из общего файла,
|
||||
/// поведение не менялось): запись события, счётчик событий с начала суток (квота вступлений) и лог задачи.
|
||||
/// </summary>
|
||||
public sealed partial class DiscoveryStore
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task AddLogAsync(string logId, string taskId, string logEvent, string text, CancellationToken ct)
|
||||
{
|
||||
_dbContext.DiscLog.Add(new DiscLogEntity
|
||||
{
|
||||
Id = logId,
|
||||
TaskId = taskId,
|
||||
Event = logEvent,
|
||||
Text = text,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<int> CountLogEventAsync(string logEvent, DateTimeOffset sinceUtc, CancellationToken ct)
|
||||
{
|
||||
// ban_guard.joins_today_auto L29–35: число событий лога по типу с начала UTC-суток (счётчик квоты).
|
||||
return await _dbContext.DiscLog.CountAsync(row => row.Event == logEvent && row.CreatedAt >= sinceUtc, ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<DiscoveryLogDto>> ListTaskLogAsync(string taskId, int limit, CancellationToken ct)
|
||||
{
|
||||
List<DiscLogEntity> rows = await _dbContext.DiscLog
|
||||
.AsNoTracking()
|
||||
.Where(log => log.TaskId == taskId)
|
||||
.OrderByDescending(log => log.CreatedAt)
|
||||
.Take(limit)
|
||||
.ToListAsync(ct);
|
||||
return rows.Select(ToLogDto).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,217 +1,221 @@
|
||||
using System.Text.Json;
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Deal.Modules.Discovery.Application;
|
||||
using Deal.Modules.Discovery.Application.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Задачи Discovery — partial-часть <see cref="DiscoveryStore"/> (C32: выделено из общего файла,
|
||||
/// поведение не менялось): CRUD задач (DiscTasks), состояние running/paused/done, атомарные инкременты
|
||||
/// счётчиков и сдвиг поиска, сумма плана (discovery.py db.py; Ruling 9, Task 17).
|
||||
/// </summary>
|
||||
public sealed partial class DiscoveryStore
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<DiscoveryTaskDto>> ListTasksAsync(CancellationToken ct)
|
||||
{
|
||||
List<DiscTaskEntity> rows = await _dbContext.DiscTasks
|
||||
.AsNoTracking()
|
||||
.OrderBy(task => task.CreatedAt)
|
||||
.ToListAsync(ct);
|
||||
return rows.Select(ToTaskDto).ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DiscoveryTaskDto?> GetTaskAsync(string taskId, CancellationToken ct)
|
||||
{
|
||||
DiscTaskEntity? row = await _dbContext.DiscTasks
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(task => task.Id == taskId, ct);
|
||||
return row is null ? null : ToTaskDto(row);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task CreateTaskAsync(DiscoveryTaskRow row, CancellationToken ct)
|
||||
{
|
||||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||
_dbContext.DiscTasks.Add(new DiscTaskEntity
|
||||
{
|
||||
Id = row.Id,
|
||||
Name = row.Name,
|
||||
Description = row.Description,
|
||||
KeywordsJson = ToJson(row.Keywords),
|
||||
MinSubscribers = row.MinSubscribers,
|
||||
Lang = row.Lang,
|
||||
Threshold = row.Threshold,
|
||||
SampleSize = row.SampleSize,
|
||||
PlanJoins = row.PlanJoins,
|
||||
AutoJoin = row.AutoJoin,
|
||||
Status = "draft",
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now,
|
||||
});
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> PatchTaskAsync(string taskId, DiscoveryTaskPatch patch, CancellationToken ct)
|
||||
{
|
||||
DiscTaskEntity? row = await _dbContext.DiscTasks
|
||||
.FirstOrDefaultAsync(task => task.Id == taskId, ct);
|
||||
if (row is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
ApplyTaskPatch(row, patch);
|
||||
row.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> DeleteTaskAsync(string taskId, CancellationToken ct)
|
||||
{
|
||||
DiscTaskEntity? row = await _dbContext.DiscTasks
|
||||
.FirstOrDefaultAsync(task => task.Id == taskId, ct);
|
||||
if (row is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// delete_task L314–318: задача удаляется вместе с кандидатами и логом; чёрный список общий — не трогаем.
|
||||
await _dbContext.DiscCandidates.Where(candidate => candidate.TaskId == taskId).ExecuteDeleteAsync(ct);
|
||||
await _dbContext.DiscLog.Where(log => log.TaskId == taskId).ExecuteDeleteAsync(ct);
|
||||
_dbContext.DiscTasks.Remove(row);
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> SetTaskRunningAsync(string taskId, bool resetProgress, CancellationToken ct)
|
||||
{
|
||||
DiscTaskEntity? row = await _dbContext.DiscTasks
|
||||
.FirstOrDefaultAsync(task => task.Id == taskId, ct);
|
||||
if (row is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
row.Status = "running";
|
||||
if (resetProgress)
|
||||
{
|
||||
// start_task L333–339: повторный прогон завершённой/упавшей — свежий проход по ключам.
|
||||
row.SearchIdx = 0;
|
||||
row.SearchDone = false;
|
||||
row.Found = 0;
|
||||
row.Evaluated = 0;
|
||||
row.Joined = 0;
|
||||
row.Rejected = 0;
|
||||
}
|
||||
|
||||
row.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> SetTaskPausedAsync(string taskId, CancellationToken ct)
|
||||
{
|
||||
DiscTaskEntity? row = await _dbContext.DiscTasks
|
||||
.FirstOrDefaultAsync(task => task.Id == taskId, ct);
|
||||
if (row is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
row.Status = "paused";
|
||||
row.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> SetTaskDoneAsync(string taskId, CancellationToken ct)
|
||||
{
|
||||
DiscTaskEntity? row = await _dbContext.DiscTasks
|
||||
.FirstOrDefaultAsync(task => task.Id == taskId, ct);
|
||||
if (row is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// воркер _finish_done L126–136: план вступлений выполнен — status=done, бюджет планов освобождается.
|
||||
row.Status = "done";
|
||||
row.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> BumpTaskCounterAsync(string taskId, DiscoveryCounterField field, int n, CancellationToken ct)
|
||||
{
|
||||
DiscTaskEntity? row = await _dbContext.DiscTasks
|
||||
.FirstOrDefaultAsync(task => task.Id == taskId, ct);
|
||||
if (row is null || n <= 0)
|
||||
{
|
||||
return row is not null;
|
||||
}
|
||||
|
||||
// bump_counter L359–368: приращение счётчика + bump UpdatedAt (python читает и пишет абсолютное значение).
|
||||
switch (field)
|
||||
{
|
||||
case DiscoveryCounterField.Found:
|
||||
row.Found += n;
|
||||
break;
|
||||
case DiscoveryCounterField.Evaluated:
|
||||
row.Evaluated += n;
|
||||
break;
|
||||
case DiscoveryCounterField.Joined:
|
||||
row.Joined += n;
|
||||
break;
|
||||
case DiscoveryCounterField.Rejected:
|
||||
row.Rejected += n;
|
||||
break;
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException(nameof(field), field, "Неизвестный счётчик задачи");
|
||||
}
|
||||
|
||||
row.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> AdvanceSearchAsync(string taskId, int nextIndex, bool searchDone, CancellationToken ct)
|
||||
{
|
||||
DiscTaskEntity? row = await _dbContext.DiscTasks
|
||||
.FirstOrDefaultAsync(task => task.Id == taskId, ct);
|
||||
if (row is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// advance_search L371–380: новый индекс и флаг завершения прохода (значения считает сервис).
|
||||
row.SearchIdx = nextIndex;
|
||||
row.SearchDone = searchDone;
|
||||
row.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<int> SumActivePlanAsync(string? excludeTaskId, CancellationToken ct)
|
||||
{
|
||||
IQueryable<DiscTaskEntity> query = _dbContext.DiscTasks
|
||||
.Where(task => task.Status != "done" && task.Status != "failed");
|
||||
if (excludeTaskId is not null)
|
||||
{
|
||||
query = query.Where(task => task.Id != excludeTaskId);
|
||||
}
|
||||
|
||||
return await query.SumAsync(task => task.PlanJoins, ct);
|
||||
}
|
||||
|
||||
}
|
||||
using System.Text.Json;
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Deal.Modules.Discovery.Application.Abstractions;
|
||||
using Deal.Modules.Discovery.Application.Exceptions;
|
||||
using Deal.Modules.Discovery.Application.Extensions;
|
||||
using Deal.Modules.Discovery.Application.Models;
|
||||
using Deal.Modules.Discovery.Application.Registrars;
|
||||
using Deal.Modules.Discovery.Application.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Задачи Discovery — partial-часть <see cref="DiscoveryStore"/> (C32: выделено из общего файла,
|
||||
/// поведение не менялось): CRUD задач (DiscTasks), состояние running/paused/done, атомарные инкременты
|
||||
/// счётчиков и сдвиг поиска, сумма плана (discovery.py db.py; Ruling 9, Task 17).
|
||||
/// </summary>
|
||||
public sealed partial class DiscoveryStore
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<DiscoveryTaskDto>> ListTasksAsync(CancellationToken ct)
|
||||
{
|
||||
List<DiscTaskEntity> rows = await _dbContext.DiscTasks
|
||||
.AsNoTracking()
|
||||
.OrderBy(task => task.CreatedAt)
|
||||
.ToListAsync(ct);
|
||||
return rows.Select(ToTaskDto).ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DiscoveryTaskDto?> GetTaskAsync(string taskId, CancellationToken ct)
|
||||
{
|
||||
DiscTaskEntity? row = await _dbContext.DiscTasks
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(task => task.Id == taskId, ct);
|
||||
return row is null ? null : ToTaskDto(row);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task CreateTaskAsync(DiscoveryTaskRow row, CancellationToken ct)
|
||||
{
|
||||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||
_dbContext.DiscTasks.Add(new DiscTaskEntity
|
||||
{
|
||||
Id = row.Id,
|
||||
Name = row.Name,
|
||||
Description = row.Description,
|
||||
KeywordsJson = ToJson(row.Keywords),
|
||||
MinSubscribers = row.MinSubscribers,
|
||||
Lang = row.Lang,
|
||||
Threshold = row.Threshold,
|
||||
SampleSize = row.SampleSize,
|
||||
PlanJoins = row.PlanJoins,
|
||||
AutoJoin = row.AutoJoin,
|
||||
Status = "draft",
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now,
|
||||
});
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> PatchTaskAsync(string taskId, DiscoveryTaskPatch patch, CancellationToken ct)
|
||||
{
|
||||
DiscTaskEntity? row = await _dbContext.DiscTasks
|
||||
.FirstOrDefaultAsync(task => task.Id == taskId, ct);
|
||||
if (row is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
ApplyTaskPatch(row, patch);
|
||||
row.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> DeleteTaskAsync(string taskId, CancellationToken ct)
|
||||
{
|
||||
DiscTaskEntity? row = await _dbContext.DiscTasks
|
||||
.FirstOrDefaultAsync(task => task.Id == taskId, ct);
|
||||
if (row is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// delete_task L314–318: задача удаляется вместе с кандидатами и логом; чёрный список общий — не трогаем.
|
||||
await _dbContext.DiscCandidates.Where(candidate => candidate.TaskId == taskId).ExecuteDeleteAsync(ct);
|
||||
await _dbContext.DiscLog.Where(log => log.TaskId == taskId).ExecuteDeleteAsync(ct);
|
||||
_dbContext.DiscTasks.Remove(row);
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> SetTaskRunningAsync(string taskId, bool resetProgress, CancellationToken ct)
|
||||
{
|
||||
DiscTaskEntity? row = await _dbContext.DiscTasks
|
||||
.FirstOrDefaultAsync(task => task.Id == taskId, ct);
|
||||
if (row is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
row.Status = "running";
|
||||
if (resetProgress)
|
||||
{
|
||||
// start_task L333–339: повторный прогон завершённой/упавшей — свежий проход по ключам.
|
||||
row.SearchIdx = 0;
|
||||
row.SearchDone = false;
|
||||
row.Found = 0;
|
||||
row.Evaluated = 0;
|
||||
row.Joined = 0;
|
||||
row.Rejected = 0;
|
||||
}
|
||||
|
||||
row.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> SetTaskPausedAsync(string taskId, CancellationToken ct)
|
||||
{
|
||||
DiscTaskEntity? row = await _dbContext.DiscTasks
|
||||
.FirstOrDefaultAsync(task => task.Id == taskId, ct);
|
||||
if (row is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
row.Status = "paused";
|
||||
row.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> SetTaskDoneAsync(string taskId, CancellationToken ct)
|
||||
{
|
||||
DiscTaskEntity? row = await _dbContext.DiscTasks
|
||||
.FirstOrDefaultAsync(task => task.Id == taskId, ct);
|
||||
if (row is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// воркер _finish_done L126–136: план вступлений выполнен — status=done, бюджет планов освобождается.
|
||||
row.Status = "done";
|
||||
row.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> BumpTaskCounterAsync(string taskId, DiscoveryCounterField field, int n, CancellationToken ct)
|
||||
{
|
||||
DiscTaskEntity? row = await _dbContext.DiscTasks
|
||||
.FirstOrDefaultAsync(task => task.Id == taskId, ct);
|
||||
if (row is null || n <= 0)
|
||||
{
|
||||
return row is not null;
|
||||
}
|
||||
|
||||
// bump_counter L359–368: приращение счётчика + bump UpdatedAt (python читает и пишет абсолютное значение).
|
||||
switch (field)
|
||||
{
|
||||
case DiscoveryCounterField.Found:
|
||||
row.Found += n;
|
||||
break;
|
||||
case DiscoveryCounterField.Evaluated:
|
||||
row.Evaluated += n;
|
||||
break;
|
||||
case DiscoveryCounterField.Joined:
|
||||
row.Joined += n;
|
||||
break;
|
||||
case DiscoveryCounterField.Rejected:
|
||||
row.Rejected += n;
|
||||
break;
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException(nameof(field), field, "Неизвестный счётчик задачи");
|
||||
}
|
||||
|
||||
row.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> AdvanceSearchAsync(string taskId, int nextIndex, bool searchDone, CancellationToken ct)
|
||||
{
|
||||
DiscTaskEntity? row = await _dbContext.DiscTasks
|
||||
.FirstOrDefaultAsync(task => task.Id == taskId, ct);
|
||||
if (row is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// advance_search L371–380: новый индекс и флаг завершения прохода (значения считает сервис).
|
||||
row.SearchIdx = nextIndex;
|
||||
row.SearchDone = searchDone;
|
||||
row.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<int> SumActivePlanAsync(string? excludeTaskId, CancellationToken ct)
|
||||
{
|
||||
IQueryable<DiscTaskEntity> query = _dbContext.DiscTasks
|
||||
.Where(task => task.Status != "done" && task.Status != "failed");
|
||||
if (excludeTaskId is not null)
|
||||
{
|
||||
query = query.Where(task => task.Id != excludeTaskId);
|
||||
}
|
||||
|
||||
return await query.SumAsync(task => task.PlanJoins, ct);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,219 +1,223 @@
|
||||
using System.Text.Json;
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Deal.Modules.Discovery.Application;
|
||||
using Deal.Modules.Discovery.Application.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// EF-адаптер хранилища Discovery: таблицы DiscTasks/DiscCandidates/DiscBlacklist/DiscLog схемы тенанта (Ruling 9, Task 17).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Реализация порта <see cref="IDiscoveryStore"/> на <see cref="TenantDbContext"/> (эталон TelegramStore/
|
||||
/// KanbanStore). Семантика 1:1 с python discovery.py/db.py: INSERT со служебными дефолтами (draft/new/счётчики 0),
|
||||
/// UPDATE присутствующих полей патча с bump UpdatedAt, атомарные инкременты счётчиков (bump_counter), upsert
|
||||
/// чёрного списка (ON CONFLICT DO UPDATE name/reason при сохранённом CreatedAt), каскад delete_task
|
||||
/// (задача + кандидаты + лог). JSON-колонки (keywords/marks/topics) — text с сериализованным JSON camelCase;
|
||||
/// времена — timestamptz (DateTimeOffset), наружу epoch-ms. Чтение Dialogs (проверка «уже мониторится») — тот же
|
||||
/// TenantDbContext (владелец каталога — модуль Telegram; доступ по БД, реверс-зависимостей нет).
|
||||
/// C32: класс разделён на partial-файлы по агрегатам (DiscoveryStore.Tasks/Candidates/Blacklist/Logs.cs);
|
||||
/// маппинг DTO ↔ строк остаётся в этом файле. Поведение и сигнатуры не менялись.
|
||||
/// </remarks>
|
||||
public sealed partial class DiscoveryStore : IDiscoveryStore
|
||||
{
|
||||
private readonly TenantDbContext _dbContext;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт EF-адаптер хранилища Discovery (зависимости — tenant-контекст БД).
|
||||
/// </summary>
|
||||
/// <param name="dbContext">Scoped-контекст тенанта запроса (search_path).</param>
|
||||
public DiscoveryStore(TenantDbContext dbContext)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
}
|
||||
|
||||
// Опции JSON: camelCase для wire-форм Discovery (1:1 с §4.8; эталон KanbanStore JsonOptions).
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
// Маппит строку задачи в DTO (task_view L116–137; keywords — JSON-список, времена — epoch-ms).
|
||||
private static DiscoveryTaskDto ToTaskDto(DiscTaskEntity row)
|
||||
{
|
||||
return new DiscoveryTaskDto(
|
||||
row.Id,
|
||||
row.Name,
|
||||
row.Description,
|
||||
FromJson<string>(row.KeywordsJson),
|
||||
row.MinSubscribers,
|
||||
row.Lang,
|
||||
row.Threshold,
|
||||
row.SampleSize,
|
||||
row.PlanJoins,
|
||||
row.AutoJoin,
|
||||
row.Status,
|
||||
row.SearchIdx,
|
||||
row.SearchDone,
|
||||
row.Found,
|
||||
row.Evaluated,
|
||||
row.Joined,
|
||||
row.Rejected,
|
||||
row.CreatedAt.ToUnixTimeMilliseconds(),
|
||||
row.UpdatedAt.ToUnixTimeMilliseconds());
|
||||
}
|
||||
|
||||
// Маппит строку кандидата в DTO (candidate_view L140–158; marks/topics — JSON-списки).
|
||||
private static DiscoveryCandidateDto ToCandidateDto(DiscCandidateEntity row)
|
||||
{
|
||||
return new DiscoveryCandidateDto(
|
||||
row.DialogId,
|
||||
row.TaskId,
|
||||
row.Name,
|
||||
row.Username,
|
||||
row.Kind,
|
||||
row.Hue,
|
||||
row.Participants,
|
||||
row.LangRu,
|
||||
FromJson<string>(row.MarksJson),
|
||||
FromJson<DiscoveryTopicDto>(row.TopicsJson),
|
||||
row.FitRatio,
|
||||
row.Status,
|
||||
row.AutoJoined,
|
||||
row.JoinFailures,
|
||||
row.CreatedAt.ToUnixTimeMilliseconds(),
|
||||
row.UpdatedAt.ToUnixTimeMilliseconds());
|
||||
}
|
||||
|
||||
// Маппит строку чёрного списка в DTO (blacklist_view L161–167).
|
||||
private static DiscoveryBlacklistDto ToBlacklistDto(DiscBlacklistEntity row)
|
||||
{
|
||||
return new DiscoveryBlacklistDto(row.DialogId, row.Name, row.Reason, row.CreatedAt.ToUnixTimeMilliseconds());
|
||||
}
|
||||
|
||||
// Маппит строку лога в DTO (log_view L170–177).
|
||||
private static DiscoveryLogDto ToLogDto(DiscLogEntity row)
|
||||
{
|
||||
return new DiscoveryLogDto(row.Id, row.TaskId, row.Event, row.Text, row.CreatedAt.ToUnixTimeMilliseconds());
|
||||
}
|
||||
|
||||
// Применяет патч задачи к строке (patch_task L292–310: только присутствующие поля; keywords — JSON).
|
||||
private static void ApplyTaskPatch(DiscTaskEntity row, DiscoveryTaskPatch patch)
|
||||
{
|
||||
if (patch.Name is not null)
|
||||
{
|
||||
row.Name = patch.Name;
|
||||
}
|
||||
|
||||
if (patch.Description is not null)
|
||||
{
|
||||
row.Description = patch.Description;
|
||||
}
|
||||
|
||||
if (patch.Keywords is not null)
|
||||
{
|
||||
row.KeywordsJson = ToJson(patch.Keywords);
|
||||
}
|
||||
|
||||
if (patch.MinSubscribers is int minSubscribers)
|
||||
{
|
||||
row.MinSubscribers = minSubscribers;
|
||||
}
|
||||
|
||||
if (patch.Lang is not null)
|
||||
{
|
||||
row.Lang = patch.Lang;
|
||||
}
|
||||
|
||||
if (patch.Threshold is int threshold)
|
||||
{
|
||||
row.Threshold = threshold;
|
||||
}
|
||||
|
||||
if (patch.SampleSize is int sampleSize)
|
||||
{
|
||||
row.SampleSize = sampleSize;
|
||||
}
|
||||
|
||||
if (patch.PlanJoins is int planJoins)
|
||||
{
|
||||
row.PlanJoins = planJoins;
|
||||
}
|
||||
|
||||
if (patch.AutoJoin is bool autoJoin)
|
||||
{
|
||||
row.AutoJoin = autoJoin;
|
||||
}
|
||||
}
|
||||
|
||||
// Применяет патч кандидата к строке (set_candidate L468–492; marks/topics — JSON-замена).
|
||||
private static void ApplyCandidatePatch(DiscCandidateEntity row, DiscoveryCandidatePatch patch)
|
||||
{
|
||||
if (patch.Name is not null)
|
||||
{
|
||||
row.Name = patch.Name;
|
||||
}
|
||||
|
||||
if (patch.Username is not null)
|
||||
{
|
||||
row.Username = patch.Username;
|
||||
}
|
||||
|
||||
if (patch.Kind is not null)
|
||||
{
|
||||
row.Kind = patch.Kind;
|
||||
}
|
||||
|
||||
if (patch.Hue is not null)
|
||||
{
|
||||
row.Hue = patch.Hue;
|
||||
}
|
||||
|
||||
if (patch.Participants is int participants)
|
||||
{
|
||||
row.Participants = participants;
|
||||
}
|
||||
|
||||
if (patch.LangRu is bool langRu)
|
||||
{
|
||||
row.LangRu = langRu;
|
||||
}
|
||||
|
||||
if (patch.Marks is not null)
|
||||
{
|
||||
row.MarksJson = ToJson(patch.Marks);
|
||||
}
|
||||
|
||||
if (patch.Topics is not null)
|
||||
{
|
||||
row.TopicsJson = ToJson(patch.Topics);
|
||||
}
|
||||
|
||||
if (patch.FitRatio is double fitRatio)
|
||||
{
|
||||
row.FitRatio = fitRatio;
|
||||
}
|
||||
|
||||
if (patch.AutoJoined is bool autoJoined)
|
||||
{
|
||||
row.AutoJoined = autoJoined;
|
||||
}
|
||||
}
|
||||
|
||||
// Разбирает JSON-массив колонки (повреждённая строка/не-массив → пустой список, python _loads L73–77).
|
||||
private static IReadOnlyList<T> FromJson<T>(string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<List<T>>(json, JsonOptions) ?? [];
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Сериализует значение в JSON (camelCase, конвенция value_json).
|
||||
private static string ToJson<T>(T value) => JsonSerializer.Serialize(value, JsonOptions);
|
||||
}
|
||||
using System.Text.Json;
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Deal.Modules.Discovery.Application.Abstractions;
|
||||
using Deal.Modules.Discovery.Application.Exceptions;
|
||||
using Deal.Modules.Discovery.Application.Extensions;
|
||||
using Deal.Modules.Discovery.Application.Models;
|
||||
using Deal.Modules.Discovery.Application.Registrars;
|
||||
using Deal.Modules.Discovery.Application.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// EF-адаптер хранилища Discovery: таблицы DiscTasks/DiscCandidates/DiscBlacklist/DiscLog схемы тенанта (Ruling 9, Task 17).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Реализация порта <see cref="IDiscoveryStore"/> на <see cref="TenantDbContext"/> (эталон TelegramStore/
|
||||
/// KanbanStore). Семантика 1:1 с python discovery.py/db.py: INSERT со служебными дефолтами (draft/new/счётчики 0),
|
||||
/// UPDATE присутствующих полей патча с bump UpdatedAt, атомарные инкременты счётчиков (bump_counter), upsert
|
||||
/// чёрного списка (ON CONFLICT DO UPDATE name/reason при сохранённом CreatedAt), каскад delete_task
|
||||
/// (задача + кандидаты + лог). JSON-колонки (keywords/marks/topics) — text с сериализованным JSON camelCase;
|
||||
/// времена — timestamptz (DateTimeOffset), наружу epoch-ms. Чтение Dialogs (проверка «уже мониторится») — тот же
|
||||
/// TenantDbContext (владелец каталога — модуль Telegram; доступ по БД, реверс-зависимостей нет).
|
||||
/// C32: класс разделён на partial-файлы по агрегатам (DiscoveryStore.Tasks/Candidates/Blacklist/Logs.cs);
|
||||
/// маппинг DTO ↔ строк остаётся в этом файле. Поведение и сигнатуры не менялись.
|
||||
/// </remarks>
|
||||
public sealed partial class DiscoveryStore : IDiscoveryStore
|
||||
{
|
||||
private readonly TenantDbContext _dbContext;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт EF-адаптер хранилища Discovery (зависимости — tenant-контекст БД).
|
||||
/// </summary>
|
||||
/// <param name="dbContext">Scoped-контекст тенанта запроса (search_path).</param>
|
||||
public DiscoveryStore(TenantDbContext dbContext)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
}
|
||||
|
||||
// Опции JSON: camelCase для wire-форм Discovery (1:1 с §4.8; эталон KanbanStore JsonOptions).
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
// Маппит строку задачи в DTO (task_view L116–137; keywords — JSON-список, времена — epoch-ms).
|
||||
private static DiscoveryTaskDto ToTaskDto(DiscTaskEntity row)
|
||||
{
|
||||
return new DiscoveryTaskDto(
|
||||
row.Id,
|
||||
row.Name,
|
||||
row.Description,
|
||||
FromJson<string>(row.KeywordsJson),
|
||||
row.MinSubscribers,
|
||||
row.Lang,
|
||||
row.Threshold,
|
||||
row.SampleSize,
|
||||
row.PlanJoins,
|
||||
row.AutoJoin,
|
||||
row.Status,
|
||||
row.SearchIdx,
|
||||
row.SearchDone,
|
||||
row.Found,
|
||||
row.Evaluated,
|
||||
row.Joined,
|
||||
row.Rejected,
|
||||
row.CreatedAt.ToUnixTimeMilliseconds(),
|
||||
row.UpdatedAt.ToUnixTimeMilliseconds());
|
||||
}
|
||||
|
||||
// Маппит строку кандидата в DTO (candidate_view L140–158; marks/topics — JSON-списки).
|
||||
private static DiscoveryCandidateDto ToCandidateDto(DiscCandidateEntity row)
|
||||
{
|
||||
return new DiscoveryCandidateDto(
|
||||
row.DialogId,
|
||||
row.TaskId,
|
||||
row.Name,
|
||||
row.Username,
|
||||
row.Kind,
|
||||
row.Hue,
|
||||
row.Participants,
|
||||
row.LangRu,
|
||||
FromJson<string>(row.MarksJson),
|
||||
FromJson<DiscoveryTopicDto>(row.TopicsJson),
|
||||
row.FitRatio,
|
||||
row.Status,
|
||||
row.AutoJoined,
|
||||
row.JoinFailures,
|
||||
row.CreatedAt.ToUnixTimeMilliseconds(),
|
||||
row.UpdatedAt.ToUnixTimeMilliseconds());
|
||||
}
|
||||
|
||||
// Маппит строку чёрного списка в DTO (blacklist_view L161–167).
|
||||
private static DiscoveryBlacklistDto ToBlacklistDto(DiscBlacklistEntity row)
|
||||
{
|
||||
return new DiscoveryBlacklistDto(row.DialogId, row.Name, row.Reason, row.CreatedAt.ToUnixTimeMilliseconds());
|
||||
}
|
||||
|
||||
// Маппит строку лога в DTO (log_view L170–177).
|
||||
private static DiscoveryLogDto ToLogDto(DiscLogEntity row)
|
||||
{
|
||||
return new DiscoveryLogDto(row.Id, row.TaskId, row.Event, row.Text, row.CreatedAt.ToUnixTimeMilliseconds());
|
||||
}
|
||||
|
||||
// Применяет патч задачи к строке (patch_task L292–310: только присутствующие поля; keywords — JSON).
|
||||
private static void ApplyTaskPatch(DiscTaskEntity row, DiscoveryTaskPatch patch)
|
||||
{
|
||||
if (patch.Name is not null)
|
||||
{
|
||||
row.Name = patch.Name;
|
||||
}
|
||||
|
||||
if (patch.Description is not null)
|
||||
{
|
||||
row.Description = patch.Description;
|
||||
}
|
||||
|
||||
if (patch.Keywords is not null)
|
||||
{
|
||||
row.KeywordsJson = ToJson(patch.Keywords);
|
||||
}
|
||||
|
||||
if (patch.MinSubscribers is int minSubscribers)
|
||||
{
|
||||
row.MinSubscribers = minSubscribers;
|
||||
}
|
||||
|
||||
if (patch.Lang is not null)
|
||||
{
|
||||
row.Lang = patch.Lang;
|
||||
}
|
||||
|
||||
if (patch.Threshold is int threshold)
|
||||
{
|
||||
row.Threshold = threshold;
|
||||
}
|
||||
|
||||
if (patch.SampleSize is int sampleSize)
|
||||
{
|
||||
row.SampleSize = sampleSize;
|
||||
}
|
||||
|
||||
if (patch.PlanJoins is int planJoins)
|
||||
{
|
||||
row.PlanJoins = planJoins;
|
||||
}
|
||||
|
||||
if (patch.AutoJoin is bool autoJoin)
|
||||
{
|
||||
row.AutoJoin = autoJoin;
|
||||
}
|
||||
}
|
||||
|
||||
// Применяет патч кандидата к строке (set_candidate L468–492; marks/topics — JSON-замена).
|
||||
private static void ApplyCandidatePatch(DiscCandidateEntity row, DiscoveryCandidatePatch patch)
|
||||
{
|
||||
if (patch.Name is not null)
|
||||
{
|
||||
row.Name = patch.Name;
|
||||
}
|
||||
|
||||
if (patch.Username is not null)
|
||||
{
|
||||
row.Username = patch.Username;
|
||||
}
|
||||
|
||||
if (patch.Kind is not null)
|
||||
{
|
||||
row.Kind = patch.Kind;
|
||||
}
|
||||
|
||||
if (patch.Hue is not null)
|
||||
{
|
||||
row.Hue = patch.Hue;
|
||||
}
|
||||
|
||||
if (patch.Participants is int participants)
|
||||
{
|
||||
row.Participants = participants;
|
||||
}
|
||||
|
||||
if (patch.LangRu is bool langRu)
|
||||
{
|
||||
row.LangRu = langRu;
|
||||
}
|
||||
|
||||
if (patch.Marks is not null)
|
||||
{
|
||||
row.MarksJson = ToJson(patch.Marks);
|
||||
}
|
||||
|
||||
if (patch.Topics is not null)
|
||||
{
|
||||
row.TopicsJson = ToJson(patch.Topics);
|
||||
}
|
||||
|
||||
if (patch.FitRatio is double fitRatio)
|
||||
{
|
||||
row.FitRatio = fitRatio;
|
||||
}
|
||||
|
||||
if (patch.AutoJoined is bool autoJoined)
|
||||
{
|
||||
row.AutoJoined = autoJoined;
|
||||
}
|
||||
}
|
||||
|
||||
// Разбирает JSON-массив колонки (повреждённая строка/не-массив → пустой список, python _loads L73–77).
|
||||
private static IReadOnlyList<T> FromJson<T>(string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<List<T>>(json, JsonOptions) ?? [];
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Сериализует значение в JSON (camelCase, конвенция value_json).
|
||||
private static string ToJson<T>(T value) => JsonSerializer.Serialize(value, JsonOptions);
|
||||
}
|
||||
|
||||
@@ -1,51 +1,53 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Deal.Modules.Settings.Application;
|
||||
using Deal.Modules.Settings.Application.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// EF-адаптер KV-хранилища глобальных (системных) настроек оператора: таблица public.global_settings.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Маппинг 1:1 со строкой таблицы: key/value/updated_at (сущность GlobalSettingEntity, системный
|
||||
/// DealDbContext схемы public). Порт оперирует готовыми JSON-строками (сериализацию выполняет
|
||||
/// владелец ключа), поэтому адаптер хранит значение как текст без интерпретации. <see cref="SetAsync"/>
|
||||
/// пишет updated_at = UTC-now.
|
||||
/// </remarks>
|
||||
public sealed class GlobalSettingsStore(DealDbContext dbContext) : IGlobalSettingsStore
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<SettingValue?> GetAsync(string key, CancellationToken ct)
|
||||
{
|
||||
var entity = await dbContext.GlobalSettings
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync(s => s.Key == key, ct);
|
||||
return entity is null ? null : new SettingValue(entity.Key, entity.Value, entity.UpdatedAt);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task SetAsync(string key, string valueJson, CancellationToken ct)
|
||||
{
|
||||
// Upsert по ключу (PK): существующая строка обновляется, отсутствующая — добавляется.
|
||||
GlobalSettingEntity? entity = await dbContext.GlobalSettings
|
||||
.SingleOrDefaultAsync(s => s.Key == key, ct);
|
||||
if (entity is null)
|
||||
{
|
||||
dbContext.GlobalSettings.Add(new GlobalSettingEntity
|
||||
{
|
||||
Key = key,
|
||||
Value = valueJson,
|
||||
UpdatedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
entity.Value = valueJson;
|
||||
entity.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Deal.Modules.Settings.Application.Abstractions;
|
||||
using Deal.Modules.Settings.Application.Models;
|
||||
using Deal.Modules.Settings.Application.Registrars;
|
||||
using Deal.Modules.Settings.Application.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// EF-адаптер KV-хранилища глобальных (системных) настроек оператора: таблица public.global_settings.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Маппинг 1:1 со строкой таблицы: key/value/updated_at (сущность GlobalSettingEntity, системный
|
||||
/// DealDbContext схемы public). Порт оперирует готовыми JSON-строками (сериализацию выполняет
|
||||
/// владелец ключа), поэтому адаптер хранит значение как текст без интерпретации. <see cref="SetAsync"/>
|
||||
/// пишет updated_at = UTC-now.
|
||||
/// </remarks>
|
||||
public sealed class GlobalSettingsStore(DealDbContext dbContext) : IGlobalSettingsStore
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<SettingValue?> GetAsync(string key, CancellationToken ct)
|
||||
{
|
||||
var entity = await dbContext.GlobalSettings
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync(s => s.Key == key, ct);
|
||||
return entity is null ? null : new SettingValue(entity.Key, entity.Value, entity.UpdatedAt);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task SetAsync(string key, string valueJson, CancellationToken ct)
|
||||
{
|
||||
// Upsert по ключу (PK): существующая строка обновляется, отсутствующая — добавляется.
|
||||
GlobalSettingEntity? entity = await dbContext.GlobalSettings
|
||||
.SingleOrDefaultAsync(s => s.Key == key, ct);
|
||||
if (entity is null)
|
||||
{
|
||||
dbContext.GlobalSettings.Add(new GlobalSettingEntity
|
||||
{
|
||||
Key = key,
|
||||
Value = valueJson,
|
||||
UpdatedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
entity.Value = valueJson;
|
||||
entity.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Deal.Modules.Tenants.Application;
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Extensions;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using Deal.Modules.Tenants.Application.Registrars;
|
||||
using Deal.Modules.Tenants.Application.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Repositories;
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
using System.Text.Json;
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Deal.Modules.Kanban.Application;
|
||||
using Deal.Modules.Kanban.Application.Abstractions;
|
||||
using Deal.Modules.Kanban.Application.Extensions;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Modules.Kanban.Application.Registrars;
|
||||
using Deal.Modules.Kanban.Application.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Repositories;
|
||||
|
||||
@@ -1,90 +1,93 @@
|
||||
using System.Text.Json;
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Deal.Modules.Cards.Application.Abstractions;
|
||||
using Deal.Modules.Cards.Application.Dtos;
|
||||
using Deal.Modules.Cards.Application.Models;
|
||||
using Deal.Modules.Kanban.Application;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Комментарии и журнал действий — partial-часть <see cref="KanbanStore"/> (C32: выделено из общего
|
||||
/// файла, поведение не менялось): LeadComments (список/добавление), CardMoves (запись/счётчик learning)
|
||||
/// и few-shot-примеры разметки для ИИ (leads.py add_comment/_log_learning/_learning_examples).
|
||||
/// </summary>
|
||||
public sealed partial class KanbanStore
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<CardCommentDto>> ListCommentsAsync(string cardId, CancellationToken ct)
|
||||
{
|
||||
List<LeadCommentEntity> entities = await _dbContext.LeadComments
|
||||
.AsNoTracking()
|
||||
.Where(comment => comment.CardId == cardId)
|
||||
.OrderBy(comment => comment.CreatedAt)
|
||||
.ThenBy(comment => comment.Id)
|
||||
.ToListAsync(ct);
|
||||
return entities.Select(ToCommentDto).ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task AddCommentAsync(string commentId, string cardId, string by, string text, CancellationToken ct)
|
||||
{
|
||||
// CreatedAt — UTC-now (leads.py add_comment L259–265). Целостность ссылки на карточку держит FK:
|
||||
// комментарий без карточки не запишется (404-семантику несуществующей карточки отдаёт сервис,
|
||||
// прочитав карточку перед добавлением, — адаптеру возвращать нечего: порт void).
|
||||
_dbContext.LeadComments.Add(new LeadCommentEntity
|
||||
{
|
||||
Id = commentId,
|
||||
CardId = cardId,
|
||||
By = by,
|
||||
Text = text,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task AddMoveAsync(CardMoveDto move, CancellationToken ct)
|
||||
{
|
||||
// Каждое действие пользователя пишет строку журнала (leads.py _log_learning L40–44); счётчик
|
||||
// learning = число записей CardMoves (Ruling 4). CreatedAt — UTC-now.
|
||||
_dbContext.CardMoves.Add(new CardMoveEntity
|
||||
{
|
||||
Id = move.Id,
|
||||
LeadId = move.LeadId,
|
||||
Action = move.Action,
|
||||
FromCol = move.FromCol,
|
||||
ToCol = move.ToCol,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<int> CountMovesAsync(CancellationToken ct) => _dbContext.CardMoves.CountAsync(ct);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<AiMarkupExampleDto>> GetAiMarkupExamplesAsync(int limit, CancellationToken ct)
|
||||
{
|
||||
// Few-shot-примеры ИИ-классификации (pipeline.py _learning_examples L201–215): join журнала с карточками,
|
||||
// действия move/restore, цель не служебная (trash/archive), source_msg непустой, свежие первыми.
|
||||
return await _dbContext.CardMoves
|
||||
.AsNoTracking()
|
||||
.Where(move => move.ToCol != null && move.ToCol != CardIds.Trash && move.ToCol != CardIds.Archive)
|
||||
.Join(
|
||||
_dbContext.Cards.AsNoTracking(),
|
||||
move => move.LeadId,
|
||||
card => card.Id,
|
||||
(move, card) => new { move, card })
|
||||
.Where(joined => (joined.move.Action == MoveAction || joined.move.Action == RestoreAction)
|
||||
&& joined.card.SourceMsg.Length > 0)
|
||||
.OrderByDescending(joined => joined.move.CreatedAt)
|
||||
.Take(limit)
|
||||
.Select(joined => new AiMarkupExampleDto(joined.card.SourceMsg, joined.move.ToCol!))
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
}
|
||||
using System.Text.Json;
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Deal.Modules.Cards.Application.Abstractions;
|
||||
using Deal.Modules.Cards.Application.Dtos;
|
||||
using Deal.Modules.Cards.Application.Models;
|
||||
using Deal.Modules.Kanban.Application.Abstractions;
|
||||
using Deal.Modules.Kanban.Application.Extensions;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Modules.Kanban.Application.Registrars;
|
||||
using Deal.Modules.Kanban.Application.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Комментарии и журнал действий — partial-часть <see cref="KanbanStore"/> (C32: выделено из общего
|
||||
/// файла, поведение не менялось): LeadComments (список/добавление), CardMoves (запись/счётчик learning)
|
||||
/// и few-shot-примеры разметки для ИИ (leads.py add_comment/_log_learning/_learning_examples).
|
||||
/// </summary>
|
||||
public sealed partial class KanbanStore
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<CardCommentDto>> ListCommentsAsync(string cardId, CancellationToken ct)
|
||||
{
|
||||
List<LeadCommentEntity> entities = await _dbContext.LeadComments
|
||||
.AsNoTracking()
|
||||
.Where(comment => comment.CardId == cardId)
|
||||
.OrderBy(comment => comment.CreatedAt)
|
||||
.ThenBy(comment => comment.Id)
|
||||
.ToListAsync(ct);
|
||||
return entities.Select(ToCommentDto).ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task AddCommentAsync(string commentId, string cardId, string by, string text, CancellationToken ct)
|
||||
{
|
||||
// CreatedAt — UTC-now (leads.py add_comment L259–265). Целостность ссылки на карточку держит FK:
|
||||
// комментарий без карточки не запишется (404-семантику несуществующей карточки отдаёт сервис,
|
||||
// прочитав карточку перед добавлением, — адаптеру возвращать нечего: порт void).
|
||||
_dbContext.LeadComments.Add(new LeadCommentEntity
|
||||
{
|
||||
Id = commentId,
|
||||
CardId = cardId,
|
||||
By = by,
|
||||
Text = text,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task AddMoveAsync(CardMoveDto move, CancellationToken ct)
|
||||
{
|
||||
// Каждое действие пользователя пишет строку журнала (leads.py _log_learning L40–44); счётчик
|
||||
// learning = число записей CardMoves (Ruling 4). CreatedAt — UTC-now.
|
||||
_dbContext.CardMoves.Add(new CardMoveEntity
|
||||
{
|
||||
Id = move.Id,
|
||||
LeadId = move.LeadId,
|
||||
Action = move.Action,
|
||||
FromCol = move.FromCol,
|
||||
ToCol = move.ToCol,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<int> CountMovesAsync(CancellationToken ct) => _dbContext.CardMoves.CountAsync(ct);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<AiMarkupExampleDto>> GetAiMarkupExamplesAsync(int limit, CancellationToken ct)
|
||||
{
|
||||
// Few-shot-примеры ИИ-классификации (pipeline.py _learning_examples L201–215): join журнала с карточками,
|
||||
// действия move/restore, цель не служебная (trash/archive), source_msg непустой, свежие первыми.
|
||||
return await _dbContext.CardMoves
|
||||
.AsNoTracking()
|
||||
.Where(move => move.ToCol != null && move.ToCol != CardIds.Trash && move.ToCol != CardIds.Archive)
|
||||
.Join(
|
||||
_dbContext.Cards.AsNoTracking(),
|
||||
move => move.LeadId,
|
||||
card => card.Id,
|
||||
(move, card) => new { move, card })
|
||||
.Where(joined => (joined.move.Action == MoveAction || joined.move.Action == RestoreAction)
|
||||
&& joined.card.SourceMsg.Length > 0)
|
||||
.OrderByDescending(joined => joined.move.CreatedAt)
|
||||
.Take(limit)
|
||||
.Select(joined => new AiMarkupExampleDto(joined.card.SourceMsg, joined.move.ToCol!))
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
}
|
||||
|
||||
@@ -2,8 +2,11 @@ using Deal.Infrastructure.Persistence.Entities;
|
||||
using Deal.Modules.Cards.Application.Abstractions;
|
||||
using Deal.Modules.Cards.Application.Dtos;
|
||||
using Deal.Modules.Cards.Application.Models;
|
||||
using Deal.Modules.Kanban.Application;
|
||||
using Deal.Modules.Kanban.Application.Abstractions;
|
||||
using Deal.Modules.Kanban.Application.Extensions;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Modules.Kanban.Application.Registrars;
|
||||
using Deal.Modules.Kanban.Application.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Repositories;
|
||||
|
||||
+147
-144
@@ -1,144 +1,147 @@
|
||||
using System.Text.Json;
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Deal.Modules.Cards.Application.Abstractions;
|
||||
using Deal.Modules.Cards.Application.Dtos;
|
||||
using Deal.Modules.Cards.Application.Models;
|
||||
using Deal.Modules.Kanban.Application;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Правила хранения, конверсии и вход эвристики suggest — partial-часть <see cref="KanbanStore"/>
|
||||
/// (C32: выделено из общего файла, поведение не менялось): кандидаты тика (автоархив/очистка архива и
|
||||
/// корзины), batch-архивация и жёсткое удаление пачки, пересчёт бюджетных конверсий и «Неразобранное»
|
||||
/// с исходным текстом для ИИ-предложений (tick_storage/recompute_conversions/suggest, Rulings 3/7/8).
|
||||
/// </summary>
|
||||
public sealed partial class KanbanStore
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<string>> ListArchiveCandidatesAsync(DateTimeOffset receivedBeforeUtc, CancellationToken ct)
|
||||
{
|
||||
// Кандидаты автоархива: карточки пользовательских колонок (kind=board) и «Неразобранного»
|
||||
// (tick_storage L462–467) — колонки динамические, поэтому id колонок читаем реестром контейнеров.
|
||||
List<string> boardIds = await _dbContext.Containers
|
||||
.AsNoTracking()
|
||||
.Where(container => container.Kind == ContainerKinds.Board)
|
||||
.Select(container => container.Id)
|
||||
.ToListAsync(ct);
|
||||
|
||||
return await _dbContext.Cards
|
||||
.AsNoTracking()
|
||||
.Where(card => card.ReceivedAt < receivedBeforeUtc
|
||||
&& (card.Col == CardIds.Inbox || boardIds.Contains(card.Col)))
|
||||
.Select(card => card.Id)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<int> ArchiveAsync(IReadOnlyList<string> cardIds, DateTimeOffset archivedAt, CancellationToken ct)
|
||||
{
|
||||
// Автоархив тика пачкой (tick_storage L462–473): один UPDATE вместо N по-карточных — как по-карточный
|
||||
// UpdateColumnAsync автоархива: col=archive, is_new=false, archived_at=now, matchHits пусто;
|
||||
// prev_col не трогается (Ruling 8). Возврат — сколько строк обновлено.
|
||||
if (cardIds.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
return await _dbContext.Cards
|
||||
.Where(card => cardIds.Contains(card.Id))
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(card => card.Col, CardIds.Archive)
|
||||
.SetProperty(card => card.IsNew, false)
|
||||
.SetProperty(card => card.ArchivedAt, archivedAt)
|
||||
.SetProperty(card => card.MatchHitsJson, ToJson(Array.Empty<MatchHitDto>())),
|
||||
ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<string>> ListExpiredArchiveCandidatesAsync(DateTimeOffset archivedBeforeUtc, CancellationToken ct)
|
||||
{
|
||||
// Очистка архива: col='archive' и archived_at старше срока (tick_storage L475–478).
|
||||
return await _dbContext.Cards
|
||||
.AsNoTracking()
|
||||
.Where(card => card.Col == CardIds.Archive
|
||||
&& card.ArchivedAt != null
|
||||
&& card.ArchivedAt < archivedBeforeUtc)
|
||||
.Select(card => card.Id)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<string>> ListTrashCandidatesAsync(DateTimeOffset receivedBeforeUtc, CancellationToken ct)
|
||||
{
|
||||
// Очистка корзины: col='trash' и received_at старше срока (tick_storage L480–483).
|
||||
return await _dbContext.Cards
|
||||
.AsNoTracking()
|
||||
.Where(card => card.Col == CardIds.Trash && card.ReceivedAt < receivedBeforeUtc)
|
||||
.Select(card => card.Id)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<int> PurgeAsync(IReadOnlyList<string> cardIds, CancellationToken ct)
|
||||
{
|
||||
// Жёсткое удаление пачки (очистки тика и clear-col, Ruling 8): комментарии чистит каскад БД,
|
||||
// строки дедупа удаляемых карточек — здесь же в транзакции (Ruling 3, _hard_delete L229).
|
||||
if (cardIds.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
await using var transaction = await _dbContext.Database.BeginTransactionAsync(ct);
|
||||
await _dbContext.DedupEntries
|
||||
.Where(entry => entry.LeadId != null && cardIds.Contains(entry.LeadId!))
|
||||
.ExecuteDeleteAsync(ct);
|
||||
int deleted = await _dbContext.Cards
|
||||
.Where(card => cardIds.Contains(card.Id))
|
||||
.ExecuteDeleteAsync(ct);
|
||||
await transaction.CommitAsync(ct);
|
||||
return deleted;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<CardDto>> ListCardsForConversionAsync(CancellationToken ct)
|
||||
{
|
||||
// Карточки с бюджетом вне служебных колонок (recompute_conversions L106–130, Ruling 7):
|
||||
// пересчёт читает только бюджетные/conv-поля, комментарии не нужны.
|
||||
List<CardEntity> entities = await _dbContext.Cards
|
||||
.AsNoTracking()
|
||||
.Where(card => card.BudgetCur != string.Empty
|
||||
&& !ConversionExcludedCols.Contains(card.Col))
|
||||
.OrderByDescending(card => card.ReceivedAt)
|
||||
.ToListAsync(ct);
|
||||
return entities.Select(card => ToCardDto(card, Array.Empty<CardCommentDto>())).ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task UpdateConversionAsync(string cardId, double? convFrom, double? convTo, string convCur, CancellationToken ct)
|
||||
{
|
||||
// Пишутся только conv-поля карточки (Ruling 7); convCur пуст — конверсия снята.
|
||||
await _dbContext.Cards
|
||||
.Where(card => card.Id == cardId)
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(card => card.ConvFrom, convFrom)
|
||||
.SetProperty(card => card.ConvTo, convTo)
|
||||
.SetProperty(card => card.ConvCur, convCur),
|
||||
ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<CardDto>> ListInboxWithSourceAsync(CancellationToken ct)
|
||||
{
|
||||
// Вход эвристики suggest (Ruling 3): «Неразобранное» с непустым source_msg — только тексты нужны.
|
||||
List<CardEntity> entities = await _dbContext.Cards
|
||||
.AsNoTracking()
|
||||
.Where(card => card.Col == CardIds.Inbox && card.SourceMsg != string.Empty)
|
||||
.OrderByDescending(card => card.ReceivedAt)
|
||||
.ToListAsync(ct);
|
||||
return entities.Select(card => ToCardDto(card, Array.Empty<CardCommentDto>())).ToList();
|
||||
}
|
||||
|
||||
}
|
||||
using System.Text.Json;
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Deal.Modules.Cards.Application.Abstractions;
|
||||
using Deal.Modules.Cards.Application.Dtos;
|
||||
using Deal.Modules.Cards.Application.Models;
|
||||
using Deal.Modules.Kanban.Application.Abstractions;
|
||||
using Deal.Modules.Kanban.Application.Extensions;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Modules.Kanban.Application.Registrars;
|
||||
using Deal.Modules.Kanban.Application.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Правила хранения, конверсии и вход эвристики suggest — partial-часть <see cref="KanbanStore"/>
|
||||
/// (C32: выделено из общего файла, поведение не менялось): кандидаты тика (автоархив/очистка архива и
|
||||
/// корзины), batch-архивация и жёсткое удаление пачки, пересчёт бюджетных конверсий и «Неразобранное»
|
||||
/// с исходным текстом для ИИ-предложений (tick_storage/recompute_conversions/suggest, Rulings 3/7/8).
|
||||
/// </summary>
|
||||
public sealed partial class KanbanStore
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<string>> ListArchiveCandidatesAsync(DateTimeOffset receivedBeforeUtc, CancellationToken ct)
|
||||
{
|
||||
// Кандидаты автоархива: карточки пользовательских колонок (kind=board) и «Неразобранного»
|
||||
// (tick_storage L462–467) — колонки динамические, поэтому id колонок читаем реестром контейнеров.
|
||||
List<string> boardIds = await _dbContext.Containers
|
||||
.AsNoTracking()
|
||||
.Where(container => container.Kind == ContainerKinds.Board)
|
||||
.Select(container => container.Id)
|
||||
.ToListAsync(ct);
|
||||
|
||||
return await _dbContext.Cards
|
||||
.AsNoTracking()
|
||||
.Where(card => card.ReceivedAt < receivedBeforeUtc
|
||||
&& (card.Col == CardIds.Inbox || boardIds.Contains(card.Col)))
|
||||
.Select(card => card.Id)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<int> ArchiveAsync(IReadOnlyList<string> cardIds, DateTimeOffset archivedAt, CancellationToken ct)
|
||||
{
|
||||
// Автоархив тика пачкой (tick_storage L462–473): один UPDATE вместо N по-карточных — как по-карточный
|
||||
// UpdateColumnAsync автоархива: col=archive, is_new=false, archived_at=now, matchHits пусто;
|
||||
// prev_col не трогается (Ruling 8). Возврат — сколько строк обновлено.
|
||||
if (cardIds.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
return await _dbContext.Cards
|
||||
.Where(card => cardIds.Contains(card.Id))
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(card => card.Col, CardIds.Archive)
|
||||
.SetProperty(card => card.IsNew, false)
|
||||
.SetProperty(card => card.ArchivedAt, archivedAt)
|
||||
.SetProperty(card => card.MatchHitsJson, ToJson(Array.Empty<MatchHitDto>())),
|
||||
ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<string>> ListExpiredArchiveCandidatesAsync(DateTimeOffset archivedBeforeUtc, CancellationToken ct)
|
||||
{
|
||||
// Очистка архива: col='archive' и archived_at старше срока (tick_storage L475–478).
|
||||
return await _dbContext.Cards
|
||||
.AsNoTracking()
|
||||
.Where(card => card.Col == CardIds.Archive
|
||||
&& card.ArchivedAt != null
|
||||
&& card.ArchivedAt < archivedBeforeUtc)
|
||||
.Select(card => card.Id)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<string>> ListTrashCandidatesAsync(DateTimeOffset receivedBeforeUtc, CancellationToken ct)
|
||||
{
|
||||
// Очистка корзины: col='trash' и received_at старше срока (tick_storage L480–483).
|
||||
return await _dbContext.Cards
|
||||
.AsNoTracking()
|
||||
.Where(card => card.Col == CardIds.Trash && card.ReceivedAt < receivedBeforeUtc)
|
||||
.Select(card => card.Id)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<int> PurgeAsync(IReadOnlyList<string> cardIds, CancellationToken ct)
|
||||
{
|
||||
// Жёсткое удаление пачки (очистки тика и clear-col, Ruling 8): комментарии чистит каскад БД,
|
||||
// строки дедупа удаляемых карточек — здесь же в транзакции (Ruling 3, _hard_delete L229).
|
||||
if (cardIds.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
await using var transaction = await _dbContext.Database.BeginTransactionAsync(ct);
|
||||
await _dbContext.DedupEntries
|
||||
.Where(entry => entry.LeadId != null && cardIds.Contains(entry.LeadId!))
|
||||
.ExecuteDeleteAsync(ct);
|
||||
int deleted = await _dbContext.Cards
|
||||
.Where(card => cardIds.Contains(card.Id))
|
||||
.ExecuteDeleteAsync(ct);
|
||||
await transaction.CommitAsync(ct);
|
||||
return deleted;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<CardDto>> ListCardsForConversionAsync(CancellationToken ct)
|
||||
{
|
||||
// Карточки с бюджетом вне служебных колонок (recompute_conversions L106–130, Ruling 7):
|
||||
// пересчёт читает только бюджетные/conv-поля, комментарии не нужны.
|
||||
List<CardEntity> entities = await _dbContext.Cards
|
||||
.AsNoTracking()
|
||||
.Where(card => card.BudgetCur != string.Empty
|
||||
&& !ConversionExcludedCols.Contains(card.Col))
|
||||
.OrderByDescending(card => card.ReceivedAt)
|
||||
.ToListAsync(ct);
|
||||
return entities.Select(card => ToCardDto(card, Array.Empty<CardCommentDto>())).ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task UpdateConversionAsync(string cardId, double? convFrom, double? convTo, string convCur, CancellationToken ct)
|
||||
{
|
||||
// Пишутся только conv-поля карточки (Ruling 7); convCur пуст — конверсия снята.
|
||||
await _dbContext.Cards
|
||||
.Where(card => card.Id == cardId)
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(card => card.ConvFrom, convFrom)
|
||||
.SetProperty(card => card.ConvTo, convTo)
|
||||
.SetProperty(card => card.ConvCur, convCur),
|
||||
ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<CardDto>> ListInboxWithSourceAsync(CancellationToken ct)
|
||||
{
|
||||
// Вход эвристики suggest (Ruling 3): «Неразобранное» с непустым source_msg — только тексты нужны.
|
||||
List<CardEntity> entities = await _dbContext.Cards
|
||||
.AsNoTracking()
|
||||
.Where(card => card.Col == CardIds.Inbox && card.SourceMsg != string.Empty)
|
||||
.OrderByDescending(card => card.ReceivedAt)
|
||||
.ToListAsync(ct);
|
||||
return entities.Select(card => ToCardDto(card, Array.Empty<CardCommentDto>())).ToList();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,320 +1,323 @@
|
||||
using System.Text.Json;
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Deal.Modules.Cards.Application.Abstractions;
|
||||
using Deal.Modules.Cards.Application.Dtos;
|
||||
using Deal.Modules.Cards.Application.Models;
|
||||
using Deal.Modules.Kanban.Application;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// EF-адаптер хранилища карточек и контейнеров (Ruling 1/12): таблицы Cards/Containers/LeadComments/CardMoves схемы тенанта.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Маппинг DTO ↔ строк выполняется вручную (эталон SettingsStore.cs): порт модуля не видит EF-сущности.
|
||||
/// JSON-поля (KeywordsJson/VisibleFieldsJson/RulesJson, StackJson/ContactsJson/MatchHitsJson) хранятся текстом
|
||||
/// с сериализованным JSON camelCase (конвенция value_json) — адаптер сериализует при записи и разбирает при чтении.
|
||||
/// Времена — timestamptz (DateTimeOffset); наружу карточки отдают receivedAt epoch-ms (ToUnixTimeMilliseconds),
|
||||
/// а human-метку «time» адаптер считает на лету от ReceivedAt/CreatedAt (Ruling 10, pipeline.py human_age L528–537).
|
||||
/// Чтения — AsNoTracking; сортировки (received_at DESC и т.п.) — здесь, как требует порт. Id записей генерирует
|
||||
/// модуль (Ruling 12) — адаптер только сохраняет готовые id. Транзакции: одиночная запись — SaveChanges
|
||||
/// (ExecuteUpdate/ExecuteDelete — одним statement'ом); методы с несколькими изменениями (DeleteContainerAsync:
|
||||
/// карточки + доска; ReorderContainersAsync: позиции всех досок) обёрнуты в явную транзакцию.
|
||||
/// Удаление карточки чистит комментарии каскадом БД (FK LeadComments → Cards, Ruling 1); журнал CardMoves
|
||||
/// и MlOutbox при удалении карточек не трогаются (прототип _hard_delete). Строки DedupEntries карточки
|
||||
/// (LeadId = id карточки) удаляются вместе с ней в той же транзакции (Ruling 3) — «сирота» не должна
|
||||
/// блокировать повторное создание карточки; таблицы соседние в том же TenantDbContext, порт Pipeline
|
||||
/// не привлекается (циклов модулей нет).
|
||||
/// C32: класс разделён на partial-файлы по темам (KanbanStore.Containers/Cards/Selected/Comments/StorageRules.cs);
|
||||
/// поведение, сигнатуры и тексты ошибок не менялись.
|
||||
/// </remarks>
|
||||
public sealed partial class KanbanStore : ICardStore
|
||||
{
|
||||
private readonly TenantDbContext _dbContext;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт EF-адаптер хранилища канбана (зависимости — tenant-контекст БД).
|
||||
/// </summary>
|
||||
/// <param name="dbContext">Контекст схемы тенанта (Containers/Cards/LeadComments/CardMoves/DedupEntries).</param>
|
||||
public KanbanStore(TenantDbContext dbContext)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
}
|
||||
|
||||
// JSON-представление «правил нет» (в DTO — null Rules, Ruling 1: как value_json настроек).
|
||||
private const string NoRulesJson = "{}";
|
||||
|
||||
// Действие журнала CardMoves «перенос» (источник few-shot-примеров, python move).
|
||||
private const string MoveAction = "move";
|
||||
|
||||
// Действие журнала CardMoves «возврат из корзины/архива» (python restore).
|
||||
private const string RestoreAction = "restore";
|
||||
|
||||
private const long MillisPerMinute = 60_000L;
|
||||
private const long MinutesPerHour = 60L;
|
||||
private const long HoursPerDay = 24L;
|
||||
|
||||
// Колонки, исключённые из пересчёта конверсий (recompute_conversions L106–130, Ruling 7).
|
||||
private static readonly string[] ConversionExcludedCols =
|
||||
[CardIds.Archive, CardIds.Trash];
|
||||
|
||||
// Id контейнеров-стадий пространства «Выбранные»: дашборд эти карточки не показывает (этап 9).
|
||||
private static readonly string[] SelectedStageIds = CardsDefaultContainers.Ids.ToArray();
|
||||
|
||||
// Стадия «Отложено» — единственная, чьи напоминания проверяет проверка напоминаний (Ruling 3).
|
||||
private const string HoldStage = CardsDefaultContainers.Hold;
|
||||
|
||||
// Опции JSON: camelCase для записей модуля Kanban (1:1 с wire-именами §4.1/§4.2).
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
|
||||
// Читает карточки пачкой с приложенными комментариями (один запрос на все id).
|
||||
// cards: Карточки (уже отсортированы запросом чтения).
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: DTO карточек в порядке входа: JSON-поля разобраны, комментарии приложены, time посчитан.
|
||||
private async Task<IReadOnlyList<CardDto>> ToCardDtosAsync(List<CardEntity> cards, CancellationToken ct)
|
||||
{
|
||||
if (cards.Count == 0)
|
||||
{
|
||||
return Array.Empty<CardDto>();
|
||||
}
|
||||
|
||||
List<string> cardIds = cards.Select(card => card.Id).ToList();
|
||||
List<LeadCommentEntity> comments = await _dbContext.LeadComments
|
||||
.AsNoTracking()
|
||||
.Where(comment => cardIds.Contains(comment.CardId))
|
||||
.OrderBy(comment => comment.CreatedAt)
|
||||
.ThenBy(comment => comment.Id)
|
||||
.ToListAsync(ct);
|
||||
ILookup<string, LeadCommentEntity> commentsByCardId = comments.ToLookup(comment => comment.CardId);
|
||||
|
||||
return cards
|
||||
.Select(card => ToCardDto(card, commentsByCardId[card.Id].Select(ToCommentDto).ToList()))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
// Маппинг строки Containers в DTO (JSON-поля разбираются).
|
||||
private static ContainerDto ToContainerDto(ContainerEntity entity) => new()
|
||||
{
|
||||
Id = entity.Id,
|
||||
Name = entity.Name,
|
||||
Description = entity.Description,
|
||||
Color = entity.Color,
|
||||
Order = entity.Position,
|
||||
Space = entity.Space,
|
||||
Kind = entity.Kind,
|
||||
Collapsed = entity.Collapsed,
|
||||
Suggested = entity.Suggested,
|
||||
Rules = ToRulesOrNull(entity.RulesJson),
|
||||
Policy = ToPolicyOrEmpty(entity.PolicyJson),
|
||||
Note = entity.Note,
|
||||
};
|
||||
|
||||
// Маппинг DTO в строку Containers (JSON-поля сериализуются; CreatedAt — UTC-now).
|
||||
private static ContainerEntity ToContainerEntity(ContainerDto container) => new()
|
||||
{
|
||||
Id = container.Id,
|
||||
Name = container.Name,
|
||||
Description = container.Description,
|
||||
Color = container.Color,
|
||||
Position = container.Order,
|
||||
Space = container.Space,
|
||||
Kind = container.Kind,
|
||||
Collapsed = container.Collapsed,
|
||||
Suggested = container.Suggested,
|
||||
RulesJson = ToRulesJson(container.Rules),
|
||||
PolicyJson = ToPolicyJson(container.Policy),
|
||||
Note = container.Note,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
|
||||
// Маппинг строки Cards в DTO: JSON-поля разобраны, budget/converted собраны из пар (from,to,cur).
|
||||
// entity: Строка карточки.
|
||||
// comments: Комментарии карточки (уже в DTO с human-меткой time).
|
||||
private static CardDto ToCardDto(CardEntity entity, IReadOnlyList<CardCommentDto> comments) => new()
|
||||
{
|
||||
Id = entity.Id,
|
||||
Col = entity.Col,
|
||||
IsNew = entity.IsNew,
|
||||
Local = entity.Local,
|
||||
IsVacancy = entity.IsVacancy,
|
||||
IsVacancyKnown = entity.IsVacancyKnown,
|
||||
Title = entity.Title,
|
||||
Summary = entity.Summary,
|
||||
Source = new CardSourceDto(
|
||||
Kind: ResolveSourceKind(entity),
|
||||
DisplayName: entity.ChannelName,
|
||||
OriginRef: entity.SourceDialogId,
|
||||
ReceivedAt: entity.ReceivedAt.ToUnixTimeMilliseconds()),
|
||||
Stack = ToJsonList<string>(entity.StackJson),
|
||||
Budget = entity.BudgetCur.Length == 0
|
||||
? null
|
||||
: new CardBudgetDto(entity.BudgetFrom, entity.BudgetTo, entity.BudgetCur),
|
||||
Converted = entity.ConvCur.Length == 0
|
||||
? null
|
||||
: new CardBudgetDto(entity.ConvFrom, entity.ConvTo, entity.ConvCur),
|
||||
Contact = entity.Contact,
|
||||
Contacts = ToJsonList<CardContactDto>(entity.ContactsJson),
|
||||
Channel = new CardChannelDto(entity.ChannelName, entity.ChannelHandle, entity.ChannelHue),
|
||||
Time = HumanAge(entity.ReceivedAt),
|
||||
ReceivedAtMs = entity.ReceivedAt.ToUnixTimeMilliseconds(),
|
||||
SourceMsg = entity.SourceMsg,
|
||||
SourceDialogId = entity.SourceDialogId,
|
||||
SourceMsgId = entity.SourceMsgId,
|
||||
PrevCol = entity.PrevCol,
|
||||
MatchHits = ToJsonList<MatchHitDto>(entity.MatchHitsJson),
|
||||
Comments = comments,
|
||||
Links = ToJsonList<CardLinkDto>(entity.LinksJson),
|
||||
Files = ToJsonList<CardFileDto>(entity.FilesJson),
|
||||
History = ToJsonList<CardHistoryDto>(entity.HistoryJson),
|
||||
TzText = entity.TzText,
|
||||
Reminder = entity.ReminderAt is { } reminderAt ? new CardReminderDto(reminderAt.ToUnixTimeMilliseconds()) : null,
|
||||
CreatedAtMs = entity.CreatedAt.ToUnixTimeMilliseconds(),
|
||||
UpdatedAtMs = entity.UpdatedAt.ToUnixTimeMilliseconds(),
|
||||
};
|
||||
|
||||
// Вид источника карточки (производная проекция): local/telegram/other.
|
||||
// entity: Строка карточки.
|
||||
// Возвращает: Вид источника (строка wire-контракта).
|
||||
private static string ResolveSourceKind(CardEntity entity)
|
||||
{
|
||||
if (entity.Local)
|
||||
{
|
||||
return "local";
|
||||
}
|
||||
|
||||
return entity.ChannelName.Length > 0 || entity.SourceDialogId.Length > 0 ? "telegram" : "other";
|
||||
}
|
||||
|
||||
// Маппинг снимка (write-модель создания карточки) в строку Cards; CreatedAt — UTC-now.
|
||||
private static CardEntity ToCardEntity(CardSnapshot snapshot) => new()
|
||||
{
|
||||
Id = snapshot.Id,
|
||||
Col = snapshot.Col,
|
||||
IsNew = snapshot.IsNew,
|
||||
Local = snapshot.Local,
|
||||
IsVacancy = snapshot.IsVacancy,
|
||||
IsVacancyKnown = snapshot.IsVacancyKnown,
|
||||
Title = snapshot.Title,
|
||||
Summary = snapshot.Summary,
|
||||
StackJson = ToJson(snapshot.Stack),
|
||||
BudgetFrom = snapshot.BudgetFrom,
|
||||
BudgetTo = snapshot.BudgetTo,
|
||||
BudgetCur = snapshot.BudgetCur,
|
||||
ConvFrom = snapshot.ConvFrom,
|
||||
ConvTo = snapshot.ConvTo,
|
||||
ConvCur = snapshot.ConvCur,
|
||||
Contact = snapshot.Contact,
|
||||
ContactsJson = ToJson(snapshot.Contacts),
|
||||
ChannelName = snapshot.ChannelName,
|
||||
ChannelHandle = snapshot.ChannelHandle,
|
||||
ChannelHue = snapshot.ChannelHue,
|
||||
ReceivedAt = snapshot.ReceivedAt,
|
||||
SourceMsg = snapshot.SourceMsg,
|
||||
SourceDialogId = snapshot.SourceDialogId,
|
||||
SourceMsgId = snapshot.SourceMsgId,
|
||||
PrevCol = snapshot.PrevCol,
|
||||
ArchivedAt = snapshot.ArchivedAt,
|
||||
MatchHitsJson = ToJson(snapshot.MatchHits),
|
||||
TzText = snapshot.TzText,
|
||||
HistoryJson = ToJson(snapshot.History),
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
|
||||
// Маппинг строки LeadComments в DTO (human-метка time от CreatedAt, Ruling 10).
|
||||
private static CardCommentDto ToCommentDto(LeadCommentEntity entity) =>
|
||||
new(entity.Id, entity.By, entity.Text, HumanAge(entity.CreatedAt));
|
||||
|
||||
// Человеческая метка возраста: «только что»/«N мин»/«N ч»/«N дн» (Ruling 10, pipeline.py human_age L528–537).
|
||||
// timestamp: Время события (получения сообщения/добавления комментария).
|
||||
// Возвращает: Метка от текущего UTC-момента; будущее время даёт «только что» (delta зажат в 0, как в прототипе).
|
||||
private static string HumanAge(DateTimeOffset timestamp)
|
||||
{
|
||||
long deltaMs = Math.Max(0, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - timestamp.ToUnixTimeMilliseconds());
|
||||
long minutes = deltaMs / MillisPerMinute;
|
||||
if (minutes < MinutesPerHour)
|
||||
{
|
||||
return minutes < 1 ? CardsService.JustNowLabel : $"{minutes} мин";
|
||||
}
|
||||
|
||||
long hours = minutes / MinutesPerHour;
|
||||
if (hours < HoursPerDay)
|
||||
{
|
||||
return $"{hours} ч";
|
||||
}
|
||||
|
||||
long days = hours / HoursPerDay;
|
||||
return $"{days} дн";
|
||||
}
|
||||
|
||||
// Разбирает JSON-массив в список; пустая/битая строка — пустой список (как json.loads в прототипе).
|
||||
private static IReadOnlyList<T> ToJsonList<T>(string json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
return Array.Empty<T>();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<List<T>>(json, JsonOptions) ?? [];
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return Array.Empty<T>();
|
||||
}
|
||||
}
|
||||
|
||||
// Разбирает rules-объект: {}/пустая/битая строка — null («правил нет»).
|
||||
private static ContainerRulesDto? ToRulesOrNull(string json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json) || json == NoRulesJson)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<ContainerRulesDto>(json, JsonOptions);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Сериализует rules-объект: null («правил нет») хранится как {}.
|
||||
private static string ToRulesJson(ContainerRulesDto? rules) =>
|
||||
rules is null ? NoRulesJson : JsonSerializer.Serialize(rules, JsonOptions);
|
||||
|
||||
// Разбирает policy-объект: пустая/битая строка — политика по умолчанию (обычная колонка).
|
||||
private static ContainerPolicyDto ToPolicyOrEmpty(string json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json) || json == NoRulesJson)
|
||||
{
|
||||
return new ContainerPolicyDto();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<ContainerPolicyDto>(json, JsonOptions) ?? new ContainerPolicyDto();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return new ContainerPolicyDto();
|
||||
}
|
||||
}
|
||||
|
||||
// Сериализует policy-объект в JSON (camelCase, конвенция value_json).
|
||||
private static string ToPolicyJson(ContainerPolicyDto policy) => JsonSerializer.Serialize(policy, JsonOptions);
|
||||
|
||||
// Сериализует значение в JSON (camelCase, конвенция value_json).
|
||||
private static string ToJson<T>(T value) => JsonSerializer.Serialize(value, JsonOptions);
|
||||
}
|
||||
|
||||
using System.Text.Json;
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Deal.Modules.Cards.Application.Abstractions;
|
||||
using Deal.Modules.Cards.Application.Dtos;
|
||||
using Deal.Modules.Cards.Application.Models;
|
||||
using Deal.Modules.Kanban.Application.Abstractions;
|
||||
using Deal.Modules.Kanban.Application.Extensions;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Modules.Kanban.Application.Registrars;
|
||||
using Deal.Modules.Kanban.Application.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// EF-адаптер хранилища карточек и контейнеров (Ruling 1/12): таблицы Cards/Containers/LeadComments/CardMoves схемы тенанта.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Маппинг DTO ↔ строк выполняется вручную (эталон SettingsStore.cs): порт модуля не видит EF-сущности.
|
||||
/// JSON-поля (KeywordsJson/VisibleFieldsJson/RulesJson, StackJson/ContactsJson/MatchHitsJson) хранятся текстом
|
||||
/// с сериализованным JSON camelCase (конвенция value_json) — адаптер сериализует при записи и разбирает при чтении.
|
||||
/// Времена — timestamptz (DateTimeOffset); наружу карточки отдают receivedAt epoch-ms (ToUnixTimeMilliseconds),
|
||||
/// а human-метку «time» адаптер считает на лету от ReceivedAt/CreatedAt (Ruling 10, pipeline.py human_age L528–537).
|
||||
/// Чтения — AsNoTracking; сортировки (received_at DESC и т.п.) — здесь, как требует порт. Id записей генерирует
|
||||
/// модуль (Ruling 12) — адаптер только сохраняет готовые id. Транзакции: одиночная запись — SaveChanges
|
||||
/// (ExecuteUpdate/ExecuteDelete — одним statement'ом); методы с несколькими изменениями (DeleteContainerAsync:
|
||||
/// карточки + доска; ReorderContainersAsync: позиции всех досок) обёрнуты в явную транзакцию.
|
||||
/// Удаление карточки чистит комментарии каскадом БД (FK LeadComments → Cards, Ruling 1); журнал CardMoves
|
||||
/// и MlOutbox при удалении карточек не трогаются (прототип _hard_delete). Строки DedupEntries карточки
|
||||
/// (LeadId = id карточки) удаляются вместе с ней в той же транзакции (Ruling 3) — «сирота» не должна
|
||||
/// блокировать повторное создание карточки; таблицы соседние в том же TenantDbContext, порт Pipeline
|
||||
/// не привлекается (циклов модулей нет).
|
||||
/// C32: класс разделён на partial-файлы по темам (KanbanStore.Containers/Cards/Selected/Comments/StorageRules.cs);
|
||||
/// поведение, сигнатуры и тексты ошибок не менялись.
|
||||
/// </remarks>
|
||||
public sealed partial class KanbanStore : ICardStore
|
||||
{
|
||||
private readonly TenantDbContext _dbContext;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт EF-адаптер хранилища канбана (зависимости — tenant-контекст БД).
|
||||
/// </summary>
|
||||
/// <param name="dbContext">Контекст схемы тенанта (Containers/Cards/LeadComments/CardMoves/DedupEntries).</param>
|
||||
public KanbanStore(TenantDbContext dbContext)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
}
|
||||
|
||||
// JSON-представление «правил нет» (в DTO — null Rules, Ruling 1: как value_json настроек).
|
||||
private const string NoRulesJson = "{}";
|
||||
|
||||
// Действие журнала CardMoves «перенос» (источник few-shot-примеров, python move).
|
||||
private const string MoveAction = "move";
|
||||
|
||||
// Действие журнала CardMoves «возврат из корзины/архива» (python restore).
|
||||
private const string RestoreAction = "restore";
|
||||
|
||||
private const long MillisPerMinute = 60_000L;
|
||||
private const long MinutesPerHour = 60L;
|
||||
private const long HoursPerDay = 24L;
|
||||
|
||||
// Колонки, исключённые из пересчёта конверсий (recompute_conversions L106–130, Ruling 7).
|
||||
private static readonly string[] ConversionExcludedCols =
|
||||
[CardIds.Archive, CardIds.Trash];
|
||||
|
||||
// Id контейнеров-стадий пространства «Выбранные»: дашборд эти карточки не показывает (этап 9).
|
||||
private static readonly string[] SelectedStageIds = CardsDefaultContainers.Ids.ToArray();
|
||||
|
||||
// Стадия «Отложено» — единственная, чьи напоминания проверяет проверка напоминаний (Ruling 3).
|
||||
private const string HoldStage = CardsDefaultContainers.Hold;
|
||||
|
||||
// Опции JSON: camelCase для записей модуля Kanban (1:1 с wire-именами §4.1/§4.2).
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
|
||||
// Читает карточки пачкой с приложенными комментариями (один запрос на все id).
|
||||
// cards: Карточки (уже отсортированы запросом чтения).
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: DTO карточек в порядке входа: JSON-поля разобраны, комментарии приложены, time посчитан.
|
||||
private async Task<IReadOnlyList<CardDto>> ToCardDtosAsync(List<CardEntity> cards, CancellationToken ct)
|
||||
{
|
||||
if (cards.Count == 0)
|
||||
{
|
||||
return Array.Empty<CardDto>();
|
||||
}
|
||||
|
||||
List<string> cardIds = cards.Select(card => card.Id).ToList();
|
||||
List<LeadCommentEntity> comments = await _dbContext.LeadComments
|
||||
.AsNoTracking()
|
||||
.Where(comment => cardIds.Contains(comment.CardId))
|
||||
.OrderBy(comment => comment.CreatedAt)
|
||||
.ThenBy(comment => comment.Id)
|
||||
.ToListAsync(ct);
|
||||
ILookup<string, LeadCommentEntity> commentsByCardId = comments.ToLookup(comment => comment.CardId);
|
||||
|
||||
return cards
|
||||
.Select(card => ToCardDto(card, commentsByCardId[card.Id].Select(ToCommentDto).ToList()))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
// Маппинг строки Containers в DTO (JSON-поля разбираются).
|
||||
private static ContainerDto ToContainerDto(ContainerEntity entity) => new()
|
||||
{
|
||||
Id = entity.Id,
|
||||
Name = entity.Name,
|
||||
Description = entity.Description,
|
||||
Color = entity.Color,
|
||||
Order = entity.Position,
|
||||
Space = entity.Space,
|
||||
Kind = entity.Kind,
|
||||
Collapsed = entity.Collapsed,
|
||||
Suggested = entity.Suggested,
|
||||
Rules = ToRulesOrNull(entity.RulesJson),
|
||||
Policy = ToPolicyOrEmpty(entity.PolicyJson),
|
||||
Note = entity.Note,
|
||||
};
|
||||
|
||||
// Маппинг DTO в строку Containers (JSON-поля сериализуются; CreatedAt — UTC-now).
|
||||
private static ContainerEntity ToContainerEntity(ContainerDto container) => new()
|
||||
{
|
||||
Id = container.Id,
|
||||
Name = container.Name,
|
||||
Description = container.Description,
|
||||
Color = container.Color,
|
||||
Position = container.Order,
|
||||
Space = container.Space,
|
||||
Kind = container.Kind,
|
||||
Collapsed = container.Collapsed,
|
||||
Suggested = container.Suggested,
|
||||
RulesJson = ToRulesJson(container.Rules),
|
||||
PolicyJson = ToPolicyJson(container.Policy),
|
||||
Note = container.Note,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
|
||||
// Маппинг строки Cards в DTO: JSON-поля разобраны, budget/converted собраны из пар (from,to,cur).
|
||||
// entity: Строка карточки.
|
||||
// comments: Комментарии карточки (уже в DTO с human-меткой time).
|
||||
private static CardDto ToCardDto(CardEntity entity, IReadOnlyList<CardCommentDto> comments) => new()
|
||||
{
|
||||
Id = entity.Id,
|
||||
Col = entity.Col,
|
||||
IsNew = entity.IsNew,
|
||||
Local = entity.Local,
|
||||
IsVacancy = entity.IsVacancy,
|
||||
IsVacancyKnown = entity.IsVacancyKnown,
|
||||
Title = entity.Title,
|
||||
Summary = entity.Summary,
|
||||
Source = new CardSourceDto(
|
||||
Kind: ResolveSourceKind(entity),
|
||||
DisplayName: entity.ChannelName,
|
||||
OriginRef: entity.SourceDialogId,
|
||||
ReceivedAt: entity.ReceivedAt.ToUnixTimeMilliseconds()),
|
||||
Stack = ToJsonList<string>(entity.StackJson),
|
||||
Budget = entity.BudgetCur.Length == 0
|
||||
? null
|
||||
: new CardBudgetDto(entity.BudgetFrom, entity.BudgetTo, entity.BudgetCur),
|
||||
Converted = entity.ConvCur.Length == 0
|
||||
? null
|
||||
: new CardBudgetDto(entity.ConvFrom, entity.ConvTo, entity.ConvCur),
|
||||
Contact = entity.Contact,
|
||||
Contacts = ToJsonList<CardContactDto>(entity.ContactsJson),
|
||||
Channel = new CardChannelDto(entity.ChannelName, entity.ChannelHandle, entity.ChannelHue),
|
||||
Time = HumanAge(entity.ReceivedAt),
|
||||
ReceivedAtMs = entity.ReceivedAt.ToUnixTimeMilliseconds(),
|
||||
SourceMsg = entity.SourceMsg,
|
||||
SourceDialogId = entity.SourceDialogId,
|
||||
SourceMsgId = entity.SourceMsgId,
|
||||
PrevCol = entity.PrevCol,
|
||||
MatchHits = ToJsonList<MatchHitDto>(entity.MatchHitsJson),
|
||||
Comments = comments,
|
||||
Links = ToJsonList<CardLinkDto>(entity.LinksJson),
|
||||
Files = ToJsonList<CardFileDto>(entity.FilesJson),
|
||||
History = ToJsonList<CardHistoryDto>(entity.HistoryJson),
|
||||
TzText = entity.TzText,
|
||||
Reminder = entity.ReminderAt is { } reminderAt ? new CardReminderDto(reminderAt.ToUnixTimeMilliseconds()) : null,
|
||||
CreatedAtMs = entity.CreatedAt.ToUnixTimeMilliseconds(),
|
||||
UpdatedAtMs = entity.UpdatedAt.ToUnixTimeMilliseconds(),
|
||||
};
|
||||
|
||||
// Вид источника карточки (производная проекция): local/telegram/other.
|
||||
// entity: Строка карточки.
|
||||
// Возвращает: Вид источника (строка wire-контракта).
|
||||
private static string ResolveSourceKind(CardEntity entity)
|
||||
{
|
||||
if (entity.Local)
|
||||
{
|
||||
return "local";
|
||||
}
|
||||
|
||||
return entity.ChannelName.Length > 0 || entity.SourceDialogId.Length > 0 ? "telegram" : "other";
|
||||
}
|
||||
|
||||
// Маппинг снимка (write-модель создания карточки) в строку Cards; CreatedAt — UTC-now.
|
||||
private static CardEntity ToCardEntity(CardSnapshot snapshot) => new()
|
||||
{
|
||||
Id = snapshot.Id,
|
||||
Col = snapshot.Col,
|
||||
IsNew = snapshot.IsNew,
|
||||
Local = snapshot.Local,
|
||||
IsVacancy = snapshot.IsVacancy,
|
||||
IsVacancyKnown = snapshot.IsVacancyKnown,
|
||||
Title = snapshot.Title,
|
||||
Summary = snapshot.Summary,
|
||||
StackJson = ToJson(snapshot.Stack),
|
||||
BudgetFrom = snapshot.BudgetFrom,
|
||||
BudgetTo = snapshot.BudgetTo,
|
||||
BudgetCur = snapshot.BudgetCur,
|
||||
ConvFrom = snapshot.ConvFrom,
|
||||
ConvTo = snapshot.ConvTo,
|
||||
ConvCur = snapshot.ConvCur,
|
||||
Contact = snapshot.Contact,
|
||||
ContactsJson = ToJson(snapshot.Contacts),
|
||||
ChannelName = snapshot.ChannelName,
|
||||
ChannelHandle = snapshot.ChannelHandle,
|
||||
ChannelHue = snapshot.ChannelHue,
|
||||
ReceivedAt = snapshot.ReceivedAt,
|
||||
SourceMsg = snapshot.SourceMsg,
|
||||
SourceDialogId = snapshot.SourceDialogId,
|
||||
SourceMsgId = snapshot.SourceMsgId,
|
||||
PrevCol = snapshot.PrevCol,
|
||||
ArchivedAt = snapshot.ArchivedAt,
|
||||
MatchHitsJson = ToJson(snapshot.MatchHits),
|
||||
TzText = snapshot.TzText,
|
||||
HistoryJson = ToJson(snapshot.History),
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
|
||||
// Маппинг строки LeadComments в DTO (human-метка time от CreatedAt, Ruling 10).
|
||||
private static CardCommentDto ToCommentDto(LeadCommentEntity entity) =>
|
||||
new(entity.Id, entity.By, entity.Text, HumanAge(entity.CreatedAt));
|
||||
|
||||
// Человеческая метка возраста: «только что»/«N мин»/«N ч»/«N дн» (Ruling 10, pipeline.py human_age L528–537).
|
||||
// timestamp: Время события (получения сообщения/добавления комментария).
|
||||
// Возвращает: Метка от текущего UTC-момента; будущее время даёт «только что» (delta зажат в 0, как в прототипе).
|
||||
private static string HumanAge(DateTimeOffset timestamp)
|
||||
{
|
||||
long deltaMs = Math.Max(0, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - timestamp.ToUnixTimeMilliseconds());
|
||||
long minutes = deltaMs / MillisPerMinute;
|
||||
if (minutes < MinutesPerHour)
|
||||
{
|
||||
return minutes < 1 ? CardsService.JustNowLabel : $"{minutes} мин";
|
||||
}
|
||||
|
||||
long hours = minutes / MinutesPerHour;
|
||||
if (hours < HoursPerDay)
|
||||
{
|
||||
return $"{hours} ч";
|
||||
}
|
||||
|
||||
long days = hours / HoursPerDay;
|
||||
return $"{days} дн";
|
||||
}
|
||||
|
||||
// Разбирает JSON-массив в список; пустая/битая строка — пустой список (как json.loads в прототипе).
|
||||
private static IReadOnlyList<T> ToJsonList<T>(string json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
return Array.Empty<T>();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<List<T>>(json, JsonOptions) ?? [];
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return Array.Empty<T>();
|
||||
}
|
||||
}
|
||||
|
||||
// Разбирает rules-объект: {}/пустая/битая строка — null («правил нет»).
|
||||
private static ContainerRulesDto? ToRulesOrNull(string json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json) || json == NoRulesJson)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<ContainerRulesDto>(json, JsonOptions);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Сериализует rules-объект: null («правил нет») хранится как {}.
|
||||
private static string ToRulesJson(ContainerRulesDto? rules) =>
|
||||
rules is null ? NoRulesJson : JsonSerializer.Serialize(rules, JsonOptions);
|
||||
|
||||
// Разбирает policy-объект: пустая/битая строка — политика по умолчанию (обычная колонка).
|
||||
private static ContainerPolicyDto ToPolicyOrEmpty(string json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json) || json == NoRulesJson)
|
||||
{
|
||||
return new ContainerPolicyDto();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<ContainerPolicyDto>(json, JsonOptions) ?? new ContainerPolicyDto();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return new ContainerPolicyDto();
|
||||
}
|
||||
}
|
||||
|
||||
// Сериализует policy-объект в JSON (camelCase, конвенция value_json).
|
||||
private static string ToPolicyJson(ContainerPolicyDto policy) => JsonSerializer.Serialize(policy, JsonOptions);
|
||||
|
||||
// Сериализует значение в JSON (camelCase, конвенция value_json).
|
||||
private static string ToJson<T>(T value) => JsonSerializer.Serialize(value, JsonOptions);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Deal.Modules.Kanban.Application;
|
||||
using Deal.Modules.Kanban.Application.Abstractions;
|
||||
using Deal.Modules.Kanban.Application.Extensions;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Modules.Kanban.Application.Registrars;
|
||||
using Deal.Modules.Kanban.Application.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Repositories;
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Deal.Modules.Tenants.Application;
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Extensions;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using Deal.Modules.Tenants.Application.Registrars;
|
||||
using Deal.Modules.Tenants.Application.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Repositories;
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Deal.Modules.Kanban.Application;
|
||||
using Deal.Modules.Pipeline.Application;
|
||||
using Deal.Modules.Kanban.Application.Abstractions;
|
||||
using Deal.Modules.Kanban.Application.Extensions;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Modules.Kanban.Application.Registrars;
|
||||
using Deal.Modules.Kanban.Application.Services;
|
||||
using Deal.Modules.Pipeline.Application.Abstractions;
|
||||
using Deal.Modules.Pipeline.Application.Models;
|
||||
using Deal.Modules.Pipeline.Application.Registrars;
|
||||
using Deal.Modules.Pipeline.Application.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Repositories;
|
||||
|
||||
@@ -2,7 +2,11 @@ using System.Data;
|
||||
using System.Data.Common;
|
||||
using System.Globalization;
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Deal.Modules.Tenants.Application;
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Extensions;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using Deal.Modules.Tenants.Application.Registrars;
|
||||
using Deal.Modules.Tenants.Application.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Repositories;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Deal.Modules.Settings.Application;
|
||||
using Deal.Modules.Settings.Application.Abstractions;
|
||||
using Deal.Modules.Settings.Application.Models;
|
||||
using Deal.Modules.Settings.Application.Registrars;
|
||||
using Deal.Modules.Settings.Application.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Repositories;
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Deal.Modules.Tenants.Application;
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Extensions;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using Deal.Modules.Tenants.Application.Registrars;
|
||||
using Deal.Modules.Tenants.Application.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Repositories;
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Deal.Modules.Tenants.Application;
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Extensions;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using Deal.Modules.Tenants.Application.Registrars;
|
||||
using Deal.Modules.Tenants.Application.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Repositories;
|
||||
|
||||
@@ -1,183 +1,186 @@
|
||||
using System.Linq.Expressions;
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Deal.Modules.Tenants.Application;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// EF-адаптер истории расхода токенов: таблица public.token_usage_events (append-only + агрегаты, этап 10, T2).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Маппинг DTO ↔ сущности вручную (порт модуля не видит EF-сущности, Ruling 1). Запись — только Add+SaveChanges.
|
||||
/// Агрегация — group by день UTC (Year/Month/Day), тенант, провайдер или модель; фильтры TenantId/Provider/Model/
|
||||
/// Kind/At-range. Порядок: day — по возрастанию даты, остальные — по убыванию total (детерминизм витрины).
|
||||
/// Неизвестный groupBy — <see cref="ArgumentException"/> (HTTP-слой валидирует и отвечает 400 раньше).
|
||||
/// </remarks>
|
||||
public sealed class TokenUsageEventStore(DealDbContext dbContext) : ITokenUsageEventStore
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task AppendAsync(TokenUsageEventDto record, CancellationToken ct)
|
||||
{
|
||||
dbContext.TokenUsageEvents.Add(new TokenUsageEventEntity
|
||||
{
|
||||
// Id генерирует БД (identity) — из DTO не копируется.
|
||||
TenantId = record.TenantId,
|
||||
At = record.At,
|
||||
Provider = record.Provider,
|
||||
Model = record.Model,
|
||||
Kind = record.Kind,
|
||||
PromptTokens = record.PromptTokens,
|
||||
CompletionTokens = record.CompletionTokens,
|
||||
TotalTokens = record.TotalTokens,
|
||||
DetailJson = record.DetailJson,
|
||||
});
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<TokenUsageAggregateDto>> AggregateAsync(TokenUsageEventQueryDto query, CancellationToken ct)
|
||||
{
|
||||
IQueryable<TokenUsageEventEntity> source = ApplyFilters(dbContext.TokenUsageEvents.AsNoTracking(), query);
|
||||
return query.GroupBy switch
|
||||
{
|
||||
TokenUsageGroupBys.Day => await AggregateByDayAsync(source, ct),
|
||||
TokenUsageGroupBys.Tenant => await AggregateByTenantAsync(source, ct),
|
||||
TokenUsageGroupBys.Provider => await AggregateByStringAsync(source, entity => entity.Provider, ct),
|
||||
TokenUsageGroupBys.Model => await AggregateByStringAsync(source, entity => entity.Model, ct),
|
||||
_ => throw new ArgumentException($"Неизвестная группировка расхода токенов: '{query.GroupBy}'.", nameof(query)),
|
||||
};
|
||||
}
|
||||
|
||||
// Применяет фильтры агрегации (TenantId/Provider/Model/Kind/At-range).
|
||||
// source: Базовый запрос.
|
||||
// query: Фильтр/группировка.
|
||||
// Возвращает: Запрос с фильтрами.
|
||||
private static IQueryable<TokenUsageEventEntity> ApplyFilters(IQueryable<TokenUsageEventEntity> source, TokenUsageEventQueryDto query)
|
||||
{
|
||||
if (query.TenantId is not null)
|
||||
{
|
||||
source = source.Where(e => e.TenantId == query.TenantId);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Provider))
|
||||
{
|
||||
source = source.Where(e => e.Provider == query.Provider);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Model))
|
||||
{
|
||||
source = source.Where(e => e.Model == query.Model);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Kind))
|
||||
{
|
||||
source = source.Where(e => e.Kind == query.Kind);
|
||||
}
|
||||
|
||||
if (query.From is not null)
|
||||
{
|
||||
source = source.Where(e => e.At >= query.From.Value);
|
||||
}
|
||||
|
||||
if (query.To is not null)
|
||||
{
|
||||
source = source.Where(e => e.At <= query.To.Value);
|
||||
}
|
||||
|
||||
return source;
|
||||
}
|
||||
|
||||
// Агрегат по суткам UTC (ключ ГГГГ-ММ-ДД), порядок — по возрастанию даты.
|
||||
// source: Отфильтрованный запрос.
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: Строки агрегатов по дням.
|
||||
private static async Task<IReadOnlyList<TokenUsageAggregateDto>> AggregateByDayAsync(
|
||||
IQueryable<TokenUsageEventEntity> source, CancellationToken ct)
|
||||
{
|
||||
var rows = await source
|
||||
.GroupBy(e => new { e.At.Year, e.At.Month, e.At.Day })
|
||||
.Select(group => new
|
||||
{
|
||||
group.Key.Year,
|
||||
group.Key.Month,
|
||||
group.Key.Day,
|
||||
Prompt = group.Sum(x => x.PromptTokens),
|
||||
Completion = group.Sum(x => x.CompletionTokens),
|
||||
Total = group.Sum(x => x.TotalTokens),
|
||||
Count = group.LongCount(),
|
||||
})
|
||||
.ToListAsync(ct);
|
||||
|
||||
return rows
|
||||
.OrderBy(row => row.Year)
|
||||
.ThenBy(row => row.Month)
|
||||
.ThenBy(row => row.Day)
|
||||
.Select(row => new TokenUsageAggregateDto(
|
||||
$"{row.Year:D4}-{row.Month:D2}-{row.Day:D2}",
|
||||
row.Prompt,
|
||||
row.Completion,
|
||||
row.Total,
|
||||
row.Count))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
// Агрегат по тенантам (ключ — Guid "D"), порядок — по убыванию total.
|
||||
// source: Отфильтрованный запрос.
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: Строки агрегатов по тенантам.
|
||||
private static async Task<IReadOnlyList<TokenUsageAggregateDto>> AggregateByTenantAsync(
|
||||
IQueryable<TokenUsageEventEntity> source, CancellationToken ct)
|
||||
{
|
||||
var rows = await source
|
||||
.GroupBy(e => e.TenantId)
|
||||
.Select(group => new
|
||||
{
|
||||
group.Key,
|
||||
Prompt = group.Sum(x => x.PromptTokens),
|
||||
Completion = group.Sum(x => x.CompletionTokens),
|
||||
Total = group.Sum(x => x.TotalTokens),
|
||||
Count = group.LongCount(),
|
||||
})
|
||||
.ToListAsync(ct);
|
||||
|
||||
return rows
|
||||
.OrderByDescending(row => row.Total)
|
||||
.Select(row => new TokenUsageAggregateDto(
|
||||
row.Key.ToString("D"),
|
||||
row.Prompt,
|
||||
row.Completion,
|
||||
row.Total,
|
||||
row.Count))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
// Агрегат по строковому ключу (провайдер/модель), порядок — по убыванию total.
|
||||
// source: Отфильтрованный запрос.
|
||||
// keySelector: Селектор ключа группы (провайдер/модель).
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: Строки агрегатов по ключу.
|
||||
private static async Task<IReadOnlyList<TokenUsageAggregateDto>> AggregateByStringAsync(
|
||||
IQueryable<TokenUsageEventEntity> source,
|
||||
Expression<Func<TokenUsageEventEntity, string>> keySelector,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var rows = await source
|
||||
.GroupBy(keySelector)
|
||||
.Select(group => new
|
||||
{
|
||||
Key = group.Key,
|
||||
Prompt = group.Sum(x => x.PromptTokens),
|
||||
Completion = group.Sum(x => x.CompletionTokens),
|
||||
Total = group.Sum(x => x.TotalTokens),
|
||||
Count = group.LongCount(),
|
||||
})
|
||||
.ToListAsync(ct);
|
||||
|
||||
return rows
|
||||
.OrderByDescending(row => row.Total)
|
||||
.Select(row => new TokenUsageAggregateDto(row.Key, row.Prompt, row.Completion, row.Total, row.Count))
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
using System.Linq.Expressions;
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Extensions;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using Deal.Modules.Tenants.Application.Registrars;
|
||||
using Deal.Modules.Tenants.Application.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// EF-адаптер истории расхода токенов: таблица public.token_usage_events (append-only + агрегаты, этап 10, T2).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Маппинг DTO ↔ сущности вручную (порт модуля не видит EF-сущности, Ruling 1). Запись — только Add+SaveChanges.
|
||||
/// Агрегация — group by день UTC (Year/Month/Day), тенант, провайдер или модель; фильтры TenantId/Provider/Model/
|
||||
/// Kind/At-range. Порядок: day — по возрастанию даты, остальные — по убыванию total (детерминизм витрины).
|
||||
/// Неизвестный groupBy — <see cref="ArgumentException"/> (HTTP-слой валидирует и отвечает 400 раньше).
|
||||
/// </remarks>
|
||||
public sealed class TokenUsageEventStore(DealDbContext dbContext) : ITokenUsageEventStore
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task AppendAsync(TokenUsageEventDto record, CancellationToken ct)
|
||||
{
|
||||
dbContext.TokenUsageEvents.Add(new TokenUsageEventEntity
|
||||
{
|
||||
// Id генерирует БД (identity) — из DTO не копируется.
|
||||
TenantId = record.TenantId,
|
||||
At = record.At,
|
||||
Provider = record.Provider,
|
||||
Model = record.Model,
|
||||
Kind = record.Kind,
|
||||
PromptTokens = record.PromptTokens,
|
||||
CompletionTokens = record.CompletionTokens,
|
||||
TotalTokens = record.TotalTokens,
|
||||
DetailJson = record.DetailJson,
|
||||
});
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<TokenUsageAggregateDto>> AggregateAsync(TokenUsageEventQueryDto query, CancellationToken ct)
|
||||
{
|
||||
IQueryable<TokenUsageEventEntity> source = ApplyFilters(dbContext.TokenUsageEvents.AsNoTracking(), query);
|
||||
return query.GroupBy switch
|
||||
{
|
||||
TokenUsageGroupBys.Day => await AggregateByDayAsync(source, ct),
|
||||
TokenUsageGroupBys.Tenant => await AggregateByTenantAsync(source, ct),
|
||||
TokenUsageGroupBys.Provider => await AggregateByStringAsync(source, entity => entity.Provider, ct),
|
||||
TokenUsageGroupBys.Model => await AggregateByStringAsync(source, entity => entity.Model, ct),
|
||||
_ => throw new ArgumentException($"Неизвестная группировка расхода токенов: '{query.GroupBy}'.", nameof(query)),
|
||||
};
|
||||
}
|
||||
|
||||
// Применяет фильтры агрегации (TenantId/Provider/Model/Kind/At-range).
|
||||
// source: Базовый запрос.
|
||||
// query: Фильтр/группировка.
|
||||
// Возвращает: Запрос с фильтрами.
|
||||
private static IQueryable<TokenUsageEventEntity> ApplyFilters(IQueryable<TokenUsageEventEntity> source, TokenUsageEventQueryDto query)
|
||||
{
|
||||
if (query.TenantId is not null)
|
||||
{
|
||||
source = source.Where(e => e.TenantId == query.TenantId);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Provider))
|
||||
{
|
||||
source = source.Where(e => e.Provider == query.Provider);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Model))
|
||||
{
|
||||
source = source.Where(e => e.Model == query.Model);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Kind))
|
||||
{
|
||||
source = source.Where(e => e.Kind == query.Kind);
|
||||
}
|
||||
|
||||
if (query.From is not null)
|
||||
{
|
||||
source = source.Where(e => e.At >= query.From.Value);
|
||||
}
|
||||
|
||||
if (query.To is not null)
|
||||
{
|
||||
source = source.Where(e => e.At <= query.To.Value);
|
||||
}
|
||||
|
||||
return source;
|
||||
}
|
||||
|
||||
// Агрегат по суткам UTC (ключ ГГГГ-ММ-ДД), порядок — по возрастанию даты.
|
||||
// source: Отфильтрованный запрос.
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: Строки агрегатов по дням.
|
||||
private static async Task<IReadOnlyList<TokenUsageAggregateDto>> AggregateByDayAsync(
|
||||
IQueryable<TokenUsageEventEntity> source, CancellationToken ct)
|
||||
{
|
||||
var rows = await source
|
||||
.GroupBy(e => new { e.At.Year, e.At.Month, e.At.Day })
|
||||
.Select(group => new
|
||||
{
|
||||
group.Key.Year,
|
||||
group.Key.Month,
|
||||
group.Key.Day,
|
||||
Prompt = group.Sum(x => x.PromptTokens),
|
||||
Completion = group.Sum(x => x.CompletionTokens),
|
||||
Total = group.Sum(x => x.TotalTokens),
|
||||
Count = group.LongCount(),
|
||||
})
|
||||
.ToListAsync(ct);
|
||||
|
||||
return rows
|
||||
.OrderBy(row => row.Year)
|
||||
.ThenBy(row => row.Month)
|
||||
.ThenBy(row => row.Day)
|
||||
.Select(row => new TokenUsageAggregateDto(
|
||||
$"{row.Year:D4}-{row.Month:D2}-{row.Day:D2}",
|
||||
row.Prompt,
|
||||
row.Completion,
|
||||
row.Total,
|
||||
row.Count))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
// Агрегат по тенантам (ключ — Guid "D"), порядок — по убыванию total.
|
||||
// source: Отфильтрованный запрос.
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: Строки агрегатов по тенантам.
|
||||
private static async Task<IReadOnlyList<TokenUsageAggregateDto>> AggregateByTenantAsync(
|
||||
IQueryable<TokenUsageEventEntity> source, CancellationToken ct)
|
||||
{
|
||||
var rows = await source
|
||||
.GroupBy(e => e.TenantId)
|
||||
.Select(group => new
|
||||
{
|
||||
group.Key,
|
||||
Prompt = group.Sum(x => x.PromptTokens),
|
||||
Completion = group.Sum(x => x.CompletionTokens),
|
||||
Total = group.Sum(x => x.TotalTokens),
|
||||
Count = group.LongCount(),
|
||||
})
|
||||
.ToListAsync(ct);
|
||||
|
||||
return rows
|
||||
.OrderByDescending(row => row.Total)
|
||||
.Select(row => new TokenUsageAggregateDto(
|
||||
row.Key.ToString("D"),
|
||||
row.Prompt,
|
||||
row.Completion,
|
||||
row.Total,
|
||||
row.Count))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
// Агрегат по строковому ключу (провайдер/модель), порядок — по убыванию total.
|
||||
// source: Отфильтрованный запрос.
|
||||
// keySelector: Селектор ключа группы (провайдер/модель).
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: Строки агрегатов по ключу.
|
||||
private static async Task<IReadOnlyList<TokenUsageAggregateDto>> AggregateByStringAsync(
|
||||
IQueryable<TokenUsageEventEntity> source,
|
||||
Expression<Func<TokenUsageEventEntity, string>> keySelector,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var rows = await source
|
||||
.GroupBy(keySelector)
|
||||
.Select(group => new
|
||||
{
|
||||
Key = group.Key,
|
||||
Prompt = group.Sum(x => x.PromptTokens),
|
||||
Completion = group.Sum(x => x.CompletionTokens),
|
||||
Total = group.Sum(x => x.TotalTokens),
|
||||
Count = group.LongCount(),
|
||||
})
|
||||
.ToListAsync(ct);
|
||||
|
||||
return rows
|
||||
.OrderByDescending(row => row.Total)
|
||||
.Select(row => new TokenUsageAggregateDto(row.Key, row.Prompt, row.Completion, row.Total, row.Count))
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user