Files
hcs/Hcs.Client/ClientApi/FileStoreServiceApi/HcsFileStoreServiceApi.cs
HOME-LAPTOP\kshkulev 33ab055b43 Add project
Basic formatting applied. Unnecessary comments have been removed. Suspicious code is covered by TODO.
2025-08-12 11:21:10 +09:00

395 lines
20 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace Hcs.ClientApi.FileStoreServiceApi
{
/// <summary>
/// Описание протокола в файле "ГИС ЖКХ. Альбом ТФФ 14.5.0.1.docx"
/// 2.5 Описание протокола обмена файлами с внешними системами
public class HcsFileStoreServiceApi
{
public HcsClientConfig Config { get; private set; }
/// <summary>
/// Максимальный размер в байтах части файла, которую разрешено
/// загружать на сервер по спецификации протокола
/// </summary>
private const int MAX_PART_LENGTH = 5242880;
public HcsFileStoreServiceApi(HcsClientConfig config)
{
this.Config = config;
}
/// <summary>
/// Путь к сервису хранения файлов ГИС ЖКХ на серевере API
/// </summary>
private const string ExtBusFileStoreServiceRest = "ext-bus-file-store-service/rest";
/// <summary>
/// Получение файла ранее загруженного в ГИС ЖКХ
/// </summary>
public async Task<HcsFile> DownloadFile(
Guid fileGuid, HcsFileStoreContext context, CancellationToken token)
{
long length = await GetFileLength(context, fileGuid, token);
if (length <= MAX_PART_LENGTH) return await DownloadSmallFile(fileGuid, context, token);
return await DownloadLargeFile(fileGuid, length, context, token);
}
/// <summary>
/// Получение файла по частям (не более 5Мб) по GUID файла
/// </summary>
private async Task<HcsFile> DownloadLargeFile(
Guid fileGuid, long fileSize, HcsFileStoreContext context, CancellationToken token)
{
if (fileSize <= MAX_PART_LENGTH)
throw new ArgumentException("Too short file for partial download");
string requestUri = ComposeFileDownloadUri(fileGuid, context);
var resultStream = new MemoryStream();
string resultContentType = null;
using (var client = BuildHttpClient())
{
long doneSize = 0;
while (doneSize < fileSize)
{
long remainderSize = fileSize - doneSize;
long partSize = Math.Min(remainderSize, MAX_PART_LENGTH);
long fromPosition = doneSize;
long toPosition = fromPosition + partSize - 1;
var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
request.Headers.Range = new RangeHeaderValue(fromPosition, toPosition);
var response = await SendRequestAsync(client, request, token);
resultContentType = response.Content.Headers.ContentType.ToString();
long? responseSize = response.Content.Headers.ContentLength;
if (responseSize == null || (long)responseSize != partSize)
throw new HcsException($"Получена часть файла длиной {responseSize}, а запрашивалась длина {partSize}");
using (var partStream = await response.Content.ReadAsStreamAsync())
{
partStream.Position = 0;
await partStream.CopyToAsync(resultStream);
}
doneSize += partSize;
}
resultStream.Position = 0;
return new HcsFile(null, resultContentType, resultStream);
}
}
private string ComposeFileDownloadUri(Guid fileGuid, HcsFileStoreContext context)
{
string endpointName = $"{ExtBusFileStoreServiceRest}/{context.GetName()}/{HcsUtil.FormatGuid(fileGuid)}?getfile";
return Config.ComposeEndpointUri(endpointName);
}
/// <summary>
/// Получение файла одной частью (не более 5Мб) по GUID файла
/// </summary>
private async Task<HcsFile> DownloadSmallFile(
Guid fileGuid, HcsFileStoreContext context, CancellationToken token)
{
string requestUri = ComposeFileDownloadUri(fileGuid, context);
using (var client = BuildHttpClient())
{
var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
var response = await SendRequestAsync(client, request, token);
return new HcsFile(
null,
response.Content.Headers.ContentType.ToString(),
await response.Content.ReadAsStreamAsync());
}
}
public async Task<Guid> UploadFile(HcsFile file, HcsFileStoreContext context, CancellationToken token)
{
if (file is null) throw new ArgumentNullException(nameof(file));
if (file.Length <= MAX_PART_LENGTH)
{
return await UploadSmallFile(file, context, token);
}
else
{
return await UploadLargeFile(file, context, token);
}
}
/// <summary>
/// Отправка большого файла по частям
/// </summary>
private async Task<Guid> UploadLargeFile(HcsFile file, HcsFileStoreContext context, CancellationToken token)
{
using var client = BuildHttpClient();
if (file.Length == 0) throw new ArgumentException("Нельзя передавать файл нулевой длины");
int numParts = (int)Math.Ceiling((double)file.Length / MAX_PART_LENGTH);
Config.Log($"Запрашиваю UploadID для большого файла {file.FileName}");
Guid uploadId = await QueryNewUploadId(client, context, file, numParts, token);
Config.Log($"Получил UploadID {uploadId} для отправки файла {file.FileName} размером {file.Length} байт");
long partOffset = 0;
for (int partNumber = 1; partNumber <= numParts; partNumber++)
{
long lengthLeft = file.Length - partOffset;
int partLength = (int)Math.Min(lengthLeft, MAX_PART_LENGTH);
var partStream = new HcsPartialStream(file.Stream, partOffset, partLength);
Config.Log($"Отправляю часть {partNumber}/{numParts} размером {partLength} байт для файла {file.FileName}");
await UploadFilePart(client, context, uploadId, partNumber, partStream, token);
partOffset += partLength;
}
Config.Log($"Отправляем признак завершения передачи файла {file.FileName}");
await CompleteUpload(client, context, uploadId, token);
Config.Log($"Файл {file.FileName} успешно передан, получен код файла {uploadId}");
return uploadId;
}
/// <summary>
/// Получение кода для загрузки большого файла из нескольких частей менее 5Мб
/// </summary>
private async Task<Guid> QueryNewUploadId(
HttpClient client, HcsFileStoreContext context, HcsFile file, int numParts, CancellationToken token)
{
string endpointName = $"{ExtBusFileStoreServiceRest}/{context.GetName()}/?upload";
string requestUri = Config.ComposeEndpointUri(endpointName);
var content = new StringContent("");
content.Headers.Add("X-Upload-Filename", CleanUploadFileName(file.FileName));
content.Headers.Add("X-Upload-Length", file.Length.ToString());
content.Headers.Add("X-Upload-Part-Count", numParts.ToString());
var request = new HttpRequestMessage(HttpMethod.Post, requestUri);
request.Content = content;
var response = await SendRequestAsync(client, request, token);
return ParseUploadIdFromReponse(response);
}
private async Task UploadFilePart(
HttpClient client, HcsFileStoreContext context, Guid uploadId, int partNumber, Stream partStream,
CancellationToken token)
{
string endpointName = $"{ExtBusFileStoreServiceRest}/{context.GetName()}/{HcsUtil.FormatGuid(uploadId)}";
string requestUri = Config.ComposeEndpointUri(endpointName);
var content = new StreamContent(partStream);
content.Headers.Add("X-Upload-Partnumber", partNumber.ToString());
content.Headers.ContentMD5 = ComputeMD5(partStream);
var request = new HttpRequestMessage(HttpMethod.Put, requestUri);
request.Content = content;
await SendRequestAsync(client, request, token);
}
private async Task CompleteUpload(HttpClient client, HcsFileStoreContext context, Guid uploadId, CancellationToken token)
{
string endpointName = $"{ExtBusFileStoreServiceRest}/{context.GetName()}/{HcsUtil.FormatGuid(uploadId)}?completed";
string requestUri = Config.ComposeEndpointUri(endpointName);
var request = new HttpRequestMessage(HttpMethod.Post, requestUri);
await SendRequestAsync(client, request, token);
}
/// <summary>
/// Загрузка в ГИС ЖКХ файла до 5Мб размером одной операцией
/// </summary>
private async Task<Guid> UploadSmallFile(HcsFile file, HcsFileStoreContext context, CancellationToken token)
{
using var client = BuildHttpClient();
string endpointName = $"{ExtBusFileStoreServiceRest}/{context.GetName()}";
string requestUri = Config.ComposeEndpointUri(endpointName);
Config.Log($"Начинаю upload малого файла [{file.FileName}] типа [{file.ContentType}] длиной {file.Length}");
if (file.Stream.Length != file.Length)
throw new HcsException($"Длина файла {file.Length} не соответствует размеру данных {file.Stream.Length}");
file.Stream.Position = 0;
var content = new StreamContent(file.Stream);
content.Headers.Add("X-Upload-Filename", CleanUploadFileName(file.FileName));
content.Headers.ContentMD5 = ComputeMD5(file.Stream);
var request = new HttpRequestMessage(HttpMethod.Put, requestUri);
request.Content = content;
var response = await SendRequestAsync(client, request, token);
return ParseUploadIdFromReponse(response);
}
/// <summary>
/// Получение информации о загружаемом или загруженном файле
/// </summary>
public async Task<long> GetFileLength(HcsFileStoreContext context, Guid fileId, CancellationToken token)
{
using var client = BuildHttpClient();
string endpointName = $"{ExtBusFileStoreServiceRest}/{context.GetName()}/{HcsUtil.FormatGuid(fileId)}";
string requestUri = Config.ComposeEndpointUri(endpointName);
var request = new HttpRequestMessage(HttpMethod.Head, requestUri);
var response = await SendRequestAsync(client, request, token);
long length = 0;
var lengthString = SearchResponseHeader(response, "X-Upload-Length");
if (!string.IsNullOrEmpty(lengthString) && long.TryParse(lengthString, out length)) return length;
throw new HcsException("В ответе сервера не указана длина файла");
}
/// <summary>
/// Возвращает вычисленное значение AttachmentHASH для данных файла @stream
/// </summary>
public string ComputeAttachmentHash(Stream stream)
{
var client = Config as HcsClient;
if (client == null) throw new HcsException("Не доступен объект HcsClient для вычиления AttachmentHASH");
// TODO: Проверить комментарий
// В декабре 2024 у меня сломалось вычисление AttachmentHASH для файлов, я стал вычислять
// явно верным алгоритмом ГОСТ94 для больших файлов уже неверное значение суммы. В январе
// 2025 путем перебора вариантов я обнаружил что ГИСЖКХ теперь вычисляет AttachmentHASH
// только по первой части большого файла.
//int hashSourceMaxLength = MAX_PART_LENGTH;
//if (stream.Length <= hashSourceMaxLength) return client.ComputeGost94Hash(stream);
//return client.ComputeGost94Hash(new HcsPartialStream(stream, 0, hashSourceMaxLength));
// 29.01.2025 СТП ГИС ЖКХ ответила что "проведены работы" и теперь
// я вижу что они снова вычисляют AttachmantHASH по полному файлу
return client.ComputeGost94Hash(stream);
}
private async Task<HttpResponseMessage> SendRequestAsync(
HttpClient client, HttpRequestMessage request, CancellationToken token)
{
token.ThrowIfCancellationRequested();
Config.Log($"Отправляю запрос {request.Method} \"{request.RequestUri}\"...");
var response = await client.SendAsync(request, token);
if (response.IsSuccessStatusCode) return response;
throw new HcsException(DescribeResponseError(response));
}
private Guid ParseUploadIdFromReponse(HttpResponseMessage response)
{
string uploadIdheaderName = "X-Upload-UploadID";
var uploadId = SearchResponseHeader(response, uploadIdheaderName);
if (uploadId != null) return HcsUtil.ParseGuid(uploadId);
throw new HcsException($"В ответе сервера нет заголовка {uploadIdheaderName}");
}
private HttpClient BuildHttpClient()
{
var _clientHandler = new HttpClientHandler();
_clientHandler.ClientCertificates.Add(Config.Certificate);
_clientHandler.ClientCertificateOptions = ClientCertificateOption.Manual;
var client = new HttpClient(_clientHandler);
client.DefaultRequestHeaders.Accept.Clear();
client.DefaultRequestHeaders.Add("X-Upload-OrgPPAGUID", Config.OrgPPAGUID);
return client;
}
private string DescribeResponseError(HttpResponseMessage response)
{
string errorHeader = "X-Upload-Error";
var message = SearchResponseHeader(response, errorHeader);
if (message != null)
{
if (knownErrors.ContainsKey(message)) return $"{errorHeader}: {message} ({knownErrors[message]})";
return $"{errorHeader}: {message}";
}
return $"HTTP response status {response.StatusCode}";
}
private string SearchResponseHeader(HttpResponseMessage response, string headerName)
{
if (response.Headers.Any(x => x.Key == headerName))
{
var pair = response.Headers.First(x => x.Key == headerName);
if (pair.Value != null && pair.Value.Any())
{
return pair.Value.First();
}
}
return null;
}
private static Dictionary<string, string> knownErrors = new Dictionary<string, string>() {
{ "FieldValidationException", "не пройдены проверки на корректность заполнения полей (обязательность, формат и т.п.)" },
{ "FileNotFoundException", "не пройдены проверки на существование файла" },
{ "InvalidStatusException", "не пройдены проверки на корректный статус файла" },
{ "InvalidSizeException", "некорректный запрашиваемый размер файла" },
{ "FileVirusInfectionException", "содержимое файла инфицировано" },
{ "FileVirusNotCheckedException", "проверка на вредоносное содержимое не выполнялась" },
{ "FilePermissionException", "организация и внешняя система не имеют полномочий на скачивание файла" },
{ "DataProviderValidationException", "поставщик данных не найден, заблокирован или неактивен" },
{ "CertificateValidationException", "информационная система не найдена по отпечатку или заблокирована" },
{ "HashConflictException", "не пройдены проверки на соответствие контрольной сумме" },
{ "InvalidPartNumberException", "не пройдены проверки на номер части (номер превышает количество частей, указанных в инициализации)" },
{ "ContextNotFoundException", "неверное имя хранилища файлов" },
{ "ExtensionException", "недопустимое расширение файла" },
{ "DetectionException", "не удалось определить MIME-тип загружаемого файла" },
{ "INT002029", "сервис недоступен: выполняются регламентные работы" }
};
private byte[] ComputeMD5(System.IO.Stream stream)
{
var position = stream.Position;
var md5 = System.Security.Cryptography.MD5.Create().ComputeHash(stream);
stream.Position = position;
return md5;
}
/// <summary>
/// Готовит имя размещаемого файла для помещения в заголовок HTTP-запроса
/// </summary>
private string CleanUploadFileName(string fileName)
{
if (fileName is null) return null;
string bannedSymbols = "<>?:|*%\\\"";
var buf = new StringBuilder();
foreach (char ch in fileName)
{
if (bannedSymbols.Contains(ch)) buf.Append('_');
else buf.Append(ch);
}
// Спецификация предписывает кодировать имя файла по стандарту MIME RFC2047
// https://datatracker.ietf.org/doc/html/rfc2047
// Как имя кодировки можно явно использовать константу "windows-1251".
string characterSet = Encoding.Default.WebName;
return EncodedWord.RFC2047.Encode(
buf.ToString(), EncodedWord.RFC2047.ContentEncoding.Base64, characterSet);
}
}
}