Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a46782920b |
@@ -0,0 +1,31 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Tnsu.ApprovalRoute.Api.Auth;
|
||||
using Tnsu.ApprovalRoute.Api.Common;
|
||||
using Tnsu.ApprovalRoute.Application.BasisRequests;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Заявки, поданные по одному основанию (СТ, потребность). Видны все, независимо
|
||||
/// от прав вызывающего на элементы; CanOpen говорит, может ли он открыть заявку.
|
||||
/// Только пользовательский токен: без email права проверять не для кого.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize(Policy = AuthPolicies.Delegated)]
|
||||
[Route("api/basis-requests")]
|
||||
[Produces("application/json")]
|
||||
public sealed class BasisRequestsController(BasisRequestsService service) : ControllerBase
|
||||
{
|
||||
/// <summary>Заявки типа requestTypeId с BasisKey = basisKey по всем партициям.</summary>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<BasisRequestDto>>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
|
||||
public async Task<IActionResult> Get(
|
||||
[FromQuery] int requestTypeId,
|
||||
// string? — пустой параметр фронтенд вырезает из URL целиком (см. RequestManageController.Search).
|
||||
[FromQuery] string? basisKey,
|
||||
CancellationToken ct) =>
|
||||
(await service.GetByBasisAsync(requestTypeId, basisKey ?? string.Empty, ct)).ToActionResult();
|
||||
}
|
||||
@@ -2,6 +2,7 @@ using System.Reflection;
|
||||
using FluentValidation;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Tnsu.ApprovalRoute.Application.Approvals;
|
||||
using Tnsu.ApprovalRoute.Application.BasisRequests;
|
||||
using Tnsu.ApprovalRoute.Application.Reminders;
|
||||
using Tnsu.ApprovalRoute.Application.ListPartitioning;
|
||||
using Tnsu.ApprovalRoute.Application.Reports;
|
||||
@@ -21,6 +22,7 @@ public static class ApplicationServiceCollectionExtensions
|
||||
services.AddScoped<RequestTypeService>();
|
||||
services.AddScoped<RequestTypeAccessService>();
|
||||
services.AddScoped<RequestManageService>();
|
||||
services.AddScoped<BasisRequestsService>();
|
||||
services.AddScoped<ListPartitionRegistry>();
|
||||
services.AddScoped<ListPartitionService>();
|
||||
// Джоба резолвится Hangfire'ом из scope на каждое выполнение.
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace Tnsu.ApprovalRoute.Application.BasisRequests;
|
||||
|
||||
/// <summary>
|
||||
/// Заявка, поданная по тому же основанию (СТ, потребность). Номер, статус и сумму
|
||||
/// видят все: от них зависит проверка «сумма оплат не больше основания». Ссылку
|
||||
/// на саму заявку фронтенд показывает только при CanOpen.
|
||||
/// </summary>
|
||||
/// <param name="Id">Id элемента списка данных.</param>
|
||||
/// <param name="ListId">Серверный относительный URL списка партиции, где лежит заявка.</param>
|
||||
/// <param name="UniqueCode">RequestUniqueCode; null — номер ещё не присвоен.</param>
|
||||
/// <param name="Status">RequestStatus заявки.</param>
|
||||
/// <param name="Amount">Сумма заявки (PayAmount).</param>
|
||||
/// <param name="CanOpen">У вызывающего есть право просмотра элемента.</param>
|
||||
public sealed record BasisRequestDto(
|
||||
int Id,
|
||||
string ListId,
|
||||
string? UniqueCode,
|
||||
string Status,
|
||||
double Amount,
|
||||
bool CanOpen);
|
||||
@@ -0,0 +1,116 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using FluentResults;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tnsu.ApprovalRoute.Application.Common.Errors;
|
||||
using Tnsu.ApprovalRoute.Application.Common.Interfaces;
|
||||
using Tnsu.ApprovalRoute.Application.ListPartitioning;
|
||||
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||||
using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Application.BasisRequests;
|
||||
|
||||
/// <summary>
|
||||
/// Заявки, поданные по одному основанию (колонка BasisKey: «ct:<Id>»,
|
||||
/// «need:<oid>») — для таблицы прежних оплат в «Заявке на оплату».
|
||||
///
|
||||
/// Читаем от имени ПРИЛОЖЕНИЯ: права на элементы заявок разорваны поштучно, и
|
||||
/// под своей учёткой заявитель видел бы только собственные заявки — сумма
|
||||
/// прежних оплат по СТ занижалась бы, а проверка превышения пропускала бы
|
||||
/// переплату. Открыть чужую заявку при этом может только тот, у кого на неё
|
||||
/// есть права: CanOpen проверяется по каждому элементу для вызывающего.
|
||||
/// </summary>
|
||||
public sealed class BasisRequestsService(
|
||||
ApprovalRouteDbContext db,
|
||||
ISharePointClient sharePoint,
|
||||
ListPartitionRegistry partitionRegistry,
|
||||
ICurrentUser currentUser)
|
||||
{
|
||||
private const string BasisKeyField = "BasisKey";
|
||||
private const string AmountField = "PayAmount";
|
||||
|
||||
// Больше по одному основанию не бывает: СТ оплачивают частями, но не сотнями.
|
||||
private const int MaxItemsPerPartition = 500;
|
||||
|
||||
// Проверка прав — отдельный запрос на элемент; ограничиваем параллельность,
|
||||
// чтобы не упереться в троттлинг SharePoint (429).
|
||||
private const int MaxParallelPermissionChecks = 4;
|
||||
|
||||
public async Task<Result<IReadOnlyList<BasisRequestDto>>> GetByBasisAsync(
|
||||
int requestTypeId, string basisKey, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(basisKey))
|
||||
return Result.Fail<IReadOnlyList<BasisRequestDto>>(new ValidationError("Не указано основание (basisKey)."));
|
||||
|
||||
var requestType = await db.RequestTypes.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.Id == requestTypeId, cancellationToken);
|
||||
if (requestType is null)
|
||||
return Result.Fail<IReadOnlyList<BasisRequestDto>>(new NotFoundError("Тип заявки", requestTypeId));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(requestType.ListId) || string.IsNullOrWhiteSpace(requestType.SiteUrl))
|
||||
return Result.Ok<IReadOnlyList<BasisRequestDto>>([]);
|
||||
|
||||
var siteUrl = requestType.SiteUrl;
|
||||
var archived = await partitionRegistry.GetArchivedAsync(requestType, cancellationToken);
|
||||
var current = await partitionRegistry.BuildCurrentAsync(requestType, archived, cancellationToken);
|
||||
var listIds = archived.Select(x => x.ListId).Append(current.ListId).ToList();
|
||||
|
||||
// Кавычка в OData-литерале удваивается; сам литерал кодируем — в ключе бывают «:» и прочее.
|
||||
var literal = Uri.EscapeDataString(basisKey.Trim().Replace("'", "''"));
|
||||
var query = $"$select=Id,RequestUniqueCode,RequestStatus,{AmountField}" +
|
||||
$"&$filter={BasisKeyField} eq '{literal}'&$orderby=Id&$top={MaxItemsPerPartition}";
|
||||
|
||||
var found = new List<(JsonElement Item, string ListId)>();
|
||||
foreach (var listId in listIds)
|
||||
{
|
||||
var items = await sharePoint.GetItemsByListUrlAsync(siteUrl, listId, query, cancellationToken);
|
||||
found.AddRange(items.Select(i => (i, listId)));
|
||||
}
|
||||
|
||||
var email = currentUser.Email;
|
||||
using var gate = new SemaphoreSlim(MaxParallelPermissionChecks);
|
||||
var result = await Task.WhenAll(found.Select(async x =>
|
||||
{
|
||||
var id = GetInt(x.Item, "Id") ?? 0;
|
||||
var canOpen = false;
|
||||
if (!string.IsNullOrWhiteSpace(email) && id > 0)
|
||||
{
|
||||
await gate.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
canOpen = await sharePoint.CanUserViewItemAsync(siteUrl, x.ListId, id, email, cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
return new BasisRequestDto(
|
||||
id,
|
||||
x.ListId,
|
||||
GetString(x.Item, "RequestUniqueCode"),
|
||||
GetString(x.Item, "RequestStatus") ?? string.Empty,
|
||||
GetDouble(x.Item, AmountField) ?? 0,
|
||||
canOpen);
|
||||
}));
|
||||
|
||||
return Result.Ok<IReadOnlyList<BasisRequestDto>>(result);
|
||||
}
|
||||
|
||||
private static string? GetString(JsonElement e, string name) =>
|
||||
e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null;
|
||||
|
||||
private static int? GetInt(JsonElement e, string name) =>
|
||||
e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.Number && v.TryGetInt32(out var i) ? i : null;
|
||||
|
||||
private static double? GetDouble(JsonElement e, string name)
|
||||
{
|
||||
if (!e.TryGetProperty(name, out var v)) return null;
|
||||
if (v.ValueKind == JsonValueKind.Number && v.TryGetDouble(out var d)) return d;
|
||||
if (v.ValueKind == JsonValueKind.String &&
|
||||
double.TryParse(v.GetString(), NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed))
|
||||
return parsed;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,14 @@ public interface ISharePointClient
|
||||
Task<IReadOnlyList<JsonElement>> GetItemsByListTitleAsync(
|
||||
string siteUrl, string listTitle, string odataQuery, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Может ли пользователь (по email) открыть элемент списка: эффективные права
|
||||
/// человека на элемент с учётом разорванного наследования, флаг ViewListItems.
|
||||
/// Неизвестный SharePoint логин — не ошибка, а «прав нет».
|
||||
/// </summary>
|
||||
Task<bool> CanUserViewItemAsync(
|
||||
string siteUrl, string listServerRelativeUrl, int itemId, string userEmail, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Запрос в SharePoint Search (postquery) от имени приложения — видит ВСЕ
|
||||
/// элементы, а не только доступные пользователю. Возвращает строки результата
|
||||
@@ -229,6 +237,22 @@ public sealed class SharePointClient(
|
||||
$"{siteUrl.TrimEnd('/')}/_api/web/lists/getByTitle('{Escape(listTitle)}')/items?{odataQuery}",
|
||||
cancellationToken);
|
||||
|
||||
public async Task<bool> CanUserViewItemAsync(
|
||||
string siteUrl, string listServerRelativeUrl, int itemId, string userEmail, CancellationToken cancellationToken)
|
||||
{
|
||||
// Claims-логин Entra ID в SharePoint Online. Передаём через параметр-псевдоним
|
||||
// @u: в пути функции «|» и «#» SharePoint не разбирает.
|
||||
var login = Uri.EscapeDataString($"i:0#.f|membership|{userEmail.Trim().ToLowerInvariant()}");
|
||||
var url = $"{siteUrl.TrimEnd('/')}/_api/web/getList('{Escape(listServerRelativeUrl)}')/items({itemId})" +
|
||||
$"/getUserEffectivePermissions(@u)?@u='{login}'";
|
||||
var json = await GetAsync(siteUrl, url, cancellationToken);
|
||||
|
||||
// BasePermissions приходит как {High, Low} — числа строками. ViewListItems — бит 0x1 в Low.
|
||||
return json.TryGetProperty("Low", out var low)
|
||||
&& ulong.TryParse(low.ValueKind == JsonValueKind.String ? low.GetString() : low.GetRawText(), out var bits)
|
||||
&& (bits & ViewListItemsMask) != 0;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<IReadOnlyDictionary<string, string?>>> SearchAsync(
|
||||
string siteUrl, string queryText, IReadOnlyList<string> selectProperties, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -426,6 +450,8 @@ public sealed class SharePointClient(
|
||||
}
|
||||
}
|
||||
|
||||
private const ulong ViewListItemsMask = 0x1;
|
||||
|
||||
// Одинарная кавычка в OData-литерале экранируется её удвоением. Заголовки
|
||||
// списков содержат тире и кириллицу, но кавычка теоретически возможна —
|
||||
// без экранирования она сломала бы запрос.
|
||||
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Moq;
|
||||
using Shouldly;
|
||||
using Tnsu.ApprovalRoute.Application.BasisRequests;
|
||||
using Tnsu.ApprovalRoute.Application.ListPartitioning;
|
||||
using Tnsu.ApprovalRoute.Application.RequestTypes;
|
||||
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||||
using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
|
||||
using Tnsu.ApprovalRoute.IntegrationTests.Infrastructure;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices;
|
||||
|
||||
/// <summary>
|
||||
/// Заявки по одному основанию: видны все (чтение от имени приложения), а
|
||||
/// CanOpen — только там, где у вызывающего есть права на элемент.
|
||||
/// </summary>
|
||||
public sealed class BasisRequestsServiceTests : IClassFixture<PostgresDatabaseFixture>
|
||||
{
|
||||
private const string SiteUrl = "https://tnsukz.sharepoint.com/sites/requests";
|
||||
private const string CallerEmail = "initiator@tnsu.kz";
|
||||
private const string BasisKey = "need:8028c7ac-ac23-8dd8-11f0-946facc42464";
|
||||
|
||||
private readonly ServiceProvider _services;
|
||||
|
||||
public BasisRequestsServiceTests(PostgresDatabaseFixture fixture)
|
||||
{
|
||||
_services = TestServiceProviderFactory.Build(fixture.ConnectionString, CallerEmail);
|
||||
}
|
||||
|
||||
private static JsonElement Json(object value) => JsonSerializer.SerializeToElement(value);
|
||||
|
||||
private async Task<(BasisRequestsService Service, Mock<ISharePointClient> SharePoint, int TypeId)> BuildAsync(
|
||||
string name, string listUrl, IReadOnlyList<object> items, IReadOnlySet<int> openableIds)
|
||||
{
|
||||
var type = await _services.GetRequiredService<RequestTypeService>().CreateAsync(
|
||||
new CreateRequestTypeRequest($"Оплата {name}", $"/оплата-{name}", listUrl, SiteUrl, "General"),
|
||||
CancellationToken.None);
|
||||
|
||||
var sharePoint = new Mock<ISharePointClient>();
|
||||
sharePoint
|
||||
.Setup(x => x.GetItemsByListUrlAsync(SiteUrl, listUrl, It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(items.Select(Json).ToList());
|
||||
sharePoint
|
||||
.Setup(x => x.CanUserViewItemAsync(SiteUrl, listUrl, It.IsAny<int>(), CallerEmail, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((string _, string _, int id, string _, CancellationToken _) => openableIds.Contains(id));
|
||||
|
||||
var service = new BasisRequestsService(
|
||||
_services.GetRequiredService<ApprovalRouteDbContext>(),
|
||||
sharePoint.Object,
|
||||
_services.GetRequiredService<ListPartitionRegistry>(),
|
||||
new TestCurrentUser(CallerEmail));
|
||||
|
||||
return (service, sharePoint, type.Value.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Returns_all_requests_and_marks_only_permitted_as_openable()
|
||||
{
|
||||
const string list = "/sites/requests/Lists/Оплата-all";
|
||||
var (service, sharePoint, typeId) = await BuildAsync("all", list,
|
||||
[
|
||||
// Number-колонка приходит дробной («50000.0») — тест держит её такой же.
|
||||
new { Id = 1, RequestUniqueCode = "24-2610-1", RequestStatus = "Согласовано", PayAmount = 50000.0 },
|
||||
new { Id = 2, RequestUniqueCode = "24-2610-2", RequestStatus = "На согласовании", PayAmount = 35000.5 }
|
||||
], new HashSet<int> { 2 });
|
||||
|
||||
var result = await service.GetByBasisAsync(typeId, BasisKey, CancellationToken.None);
|
||||
|
||||
result.IsSuccess.ShouldBeTrue();
|
||||
result.Value.Count.ShouldBe(2);
|
||||
result.Value.Single(x => x.Id == 1).ShouldBe(new BasisRequestDto(1, list, "24-2610-1", "Согласовано", 50000, false));
|
||||
result.Value.Single(x => x.Id == 2).ShouldBe(new BasisRequestDto(2, list, "24-2610-2", "На согласовании", 35000.5, true));
|
||||
|
||||
// Фильтр по основанию уходит в SharePoint, а не применяется в памяти.
|
||||
sharePoint.Verify(x => x.GetItemsByListUrlAsync(
|
||||
SiteUrl, list,
|
||||
It.Is<string>(q => q.Contains("BasisKey eq 'need%3A8028c7ac-ac23-8dd8-11f0-946facc42464'")),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Empty_basis_key_is_a_validation_error()
|
||||
{
|
||||
var (service, _, typeId) = await BuildAsync("empty", "/sites/requests/Lists/Оплата-empty", [], new HashSet<int>());
|
||||
|
||||
var result = await service.GetByBasisAsync(typeId, " ", CancellationToken.None);
|
||||
|
||||
result.IsFailed.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user