Перейти к содержанию

Временные ссылки на контент системы

Серверная реализация обработчика контента в проектном решении

Рассмотрим пример создания обработчика контента в рамках проектного решения:

  • Контентом в данном примере будут аватары контрагентов (фото не учитывается).
  • Ограничим время действия токена тремя днями, а время кэширования контента двумя днями.
  • Как и в случае аватаров пользователей, токен будет привязан к пользователю.
  • Будем полагать, что файлы аватаров:
    • могут быть установлены из карточки контрагента с помощью разработанной кнопки “Установить аватар”;
    • могут прикрепляться к карточке контрагента с названием "avatar.jpg|jpeg", и размер такого файла не превышает 20 КиБ.
  • Если аватар для контрагента не задан, то формируется стандартный аватар в формате svg с инициалами контрагента.

Обработка ссылок на контент осуществляется на уровне платформы. В серверной части проектного решения необходимо:

  1. Определить тип для контента (не требует регистрации).
  2. Реализовать интерфейсы провайдеров IAccessTokenProvider и IContentProvider, которые содержат всю необходимую логику по созданию токена и возврату запрашиваемого контента.
  3. Зарегистрировать обработчик в контейнере Unity с именем, соответствующим типу контента.

В проекте Tessa.Extensions.Shared необходимо создать папку Content, в которой будет размещён класс AbContentTypes. Здесь Ab - это короткий префикс проектного решения, который также будет использован в именах типов далее в этом примере. В проектном решении может быть другой префикс.

AbContentTypes.cs

namespace Tessa.Extensions.Shared.Content { /// <summary> /// Типы контента. /// </summary> public static class AbContentTypes { /// <summary> /// Тип контента для аватаров контрагентов. /// </summary> public const string PartnerAvatar = "partner-avatars"; } }

В проекте Tessa.Extensions.Server также необходимо создать папку Content, в которой будут размещены интерфейсы и классы.

Note

В данном примере интерфейсы и классы размещены в одних и тех же файлах для простоты. В реальном коде рекомендуется разносить все объекты в отдельные файлы.

В папку Content необходимо добавить провайдер PartnerAvatarContentTokenProvider для токена доступа к содержимому аватара контрагента:

PartnerAvatarContentTokenProvider.cs

using System; using Tessa.Content.Avatars; using Tessa.Extensions.Shared.Content; using Tessa.Platform; using Tessa.Platform.Runtime; using Tessa.Roles; using Tessa.Tokens;

namespace Tessa.Extensions.Server.Content { /// <summary> /// Предоставляет токен для получения контента аватаров контрагентов. /// </summary> public interface IPartnerAvatarContentTokenProvider : IAvatarContentTokenProvider;

/// <inheritdoc cref="IPartnerAvatarContentTokenProvider"/> /// <inheritdoc cref="AvatarContentTokenProvider"/> public sealed class PartnerAvatarContentTokenProvider( IClock clock, ISession session, ITessaServerSettings serverSettings, IRoleGetStrategy roleGetStrategy, ITokenRepository tokenRepository, TokenInfoBuilderFactory tokenBuilderFactory) : AvatarContentTokenProvider( clock, session, serverSettings, roleGetStrategy, tokenRepository, tokenBuilderFactory), IPartnerAvatarContentTokenProvider { /// <inheritdoc/> protected override string Scope => AbContentTypes.PartnerAvatar;

/// <inheritdoc/> protected override TimeSpan Expiration => TimeSpan.FromDays(3); } }

В объекте-провайдере можно описать дополнительную логику хранения, кэширования и получения токенов для аватаров, которая не описана в базовом объекте AvatarContentTokenProvider - провайдер токенов типа Avatar.

Далее в текущую папку необходимо добавить менеджер прав для работы с содержимым аватаров PartnerAvatarContentPermissionsManager:

PartnerAvatarContentPermissionsManager.cs

using System; using System.Linq; using System.Threading; using System.Threading.Tasks; using LinqToDB; using Tessa.Content; using Tessa.Content.Avatars; using Tessa.Platform.Data; using Tessa.Platform.Runtime; using Tessa.Platform.Validation;

namespace Tessa.Extensions.Server.Content { /// <summary> /// Представляет объект, отвечающий за проверку прав доступа к аватару контрагента. /// </summary> public interface IPartnerAvatarContentPermissionsManager : IAvatarContentPermissionsManager;

/// <inheritdoc cref="IPartnerAvatarContentPermissionsManager"/> /// <inheritdoc cref="AvatarContentPermissionsManager"/> public sealed class PartnerAvatarContentPermissionsManager(IDbScope dbScope, ISession session) : AvatarContentPermissionsManager(dbScope, session), IPartnerAvatarContentPermissionsManager { #region IPartnerAvatarPermissionsManager

/// <inheritdoc/> public override async ValueTask<ValidationResult> CheckContentRequestAsync( ContentRequest request, CancellationToken cancellationToken = default) { ThrowIfNull(request);

if (!AvatarContentHelper.TryExtractAvatarInfo(request.ContentID, out var id, out var kind)) { return ValidationSequence .Begin() .SetObjectName(this) .ErrorText("$Content_Message_InvalidContentID") .End() .Build(); }

if (!await this.IsPartnerExistsAsync(id.Value, cancellationToken)) { return ValidationSequence .Begin() .SetObjectName(this) .ErrorText($"Контрагент с идентификатором {id.Value} не существует.") .End() .Build(); }

return ValidationResult.Empty; }

#endregion

#region Private Methods

private async Task<bool> IsPartnerExistsAsync(Guid partnerID, CancellationToken cancellationToken) { await using var _ = this.DbScope.Create(); var builderFactory = await this.DbScope.GetBuilderFactoryAsync(cancellationToken);

var query = builderFactory.Cached(this, "GetPartner", static builder => builder .Select().Top(1).V(true) .From("Partners").NoLock() .Where().C("ID").Equals().P("PartnerID") .Limit(1) .Build());

return await this.DbScope.Db .SetCommand(query, this.DbScope.Db.Parameter("PartnerID", partnerID, DataType.Guid)) .LogCommand() .ExecuteAsync<bool>(cancellationToken); }

#endregion } }

В объекте-менеджере можно описать дополнительную логику, которая не описана в базовом объекте AvatarContentPermissionsManager, а именно: проверка запроса на получение содержимого аватара и проверка прав доступа для изменения аватара контрагента.

Далее в текущую папку необходимо добавить объект PartnerAvatarContentRepository:

PartnerAvatarContentRepository.cs

using System; using System.Linq; using System.Net.Mime; using System.Threading; using System.Threading.Tasks; using LinqToDB; using Tessa.Cards; using Tessa.Content; using Tessa.Content.Avatars; using Tessa.Platform.Data; using Tessa.Platform.Validation;

namespace Tessa.Extensions.Server.Content { /// <summary> /// Репозиторий для управления контентом аватарами контрагентов. /// </summary> public interface IPartnerAvatarContentRepository : IAvatarGeneratedContentRepository;

/// <inheritdoc cref="IPartnerAvatarContentRepository"/> /// <inheritdoc cref="AvatarGeneratedContentRepository"/> /// <param name="cardStreamServerRepository"><inheritdoc cref="ICardStreamServerRepository" path="/summary"/></param> public sealed class PartnerAvatarContentRepository( IDbScope dbScope, IAvatarGeneratedContentCache avatarCache, ICardStreamServerRepository cardStreamServerRepository) : AvatarGeneratedContentRepository(dbScope, avatarCache), IPartnerAvatarContentRepository { #region Constants

private const int MaxImageSizeInBytes = 20 * 1024;

#endregion

#region Fields

private readonly ICardStreamServerRepository cardStreamServerRepository = NotNullOrThrow(cardStreamServerRepository); private static readonly string[] avatarPatterns = { "avatar.jpg", "avatar.jpeg" };

#endregion

#region Base Overrides

/// <inheritdoc/> public override async Task<bool> ExistsContentAsync( Guid id, AvatarContentKind kind, CancellationToken cancellationToken = default) { return await base.ExistsContentAsync(id, kind, cancellationToken) || await this.ExistsFileContentAsync(id, cancellationToken); }

/// <inheritdoc/> public override async Task<ContentResult?> TryGetContentAsync( Guid id, AvatarContentKind kind, CancellationToken cancellationToken = default) { // Формирование запроса на получение файла аватара из карточки контрагента var fileRequest = await this.GetSourceFileContentRequestAsync(id, cancellationToken); if (fileRequest is null) { // При отсутствии подходящего файла выполняется базовая логика для получения аватара из БД return await base.TryGetContentAsync(id, kind, cancellationToken); }

var fileContentResult = await this.cardStreamServerRepository.GetFileContentAsync(fileRequest, cancellationToken); var validationResult = fileContentResult.Response.ValidationResult.Build(); if (!validationResult.IsSuccessful || !fileContentResult.HasContent) { throw new ValidationException(validationResult); }

var stream = await fileContentResult.GetContentOrThrowAsync(cancellationToken); var contentResult = new ContentResult(stream, fileRequest.FileName!, MediaTypeNames.Image.Jpeg);

return contentResult; }

#endregion

#region Private Methods

private async Task<CardGetFileContentRequest?> GetSourceFileContentRequestAsync( Guid partnerID, CancellationToken cancellationToken) { await using var _ = this.DbScope.Create(); var builderFactory = await this.DbScope.GetBuilderFactoryAsync(cancellationToken);

var query = builderFactory.Cached(this, "GetFileRequest", static builder => builder .Select().Top(1) .C("f", "ID").As(nameof(CardGetFileContentRequest.CardID)) .C("f", "RowID").As(nameof(CardGetFileContentRequest.FileID)) .C("f", "VersionRowID").As(nameof(CardGetFileContentRequest.VersionRowID)) .C("f", "Name").As(nameof(CardGetFileContentRequest.FileName)) .C("f", "TypeID").As(nameof(CardGetFileContentRequest.FileTypeID)) .From("Files", "f").NoLock() .InnerJoin("FileVersions", "fv").NoLock() .On().C("fv", "RowID").Equals().C("f", "VersionRowID") .Where().C("f", "ID").Equals().P("PartnerID") .And().LowerC("f", "Name").In(avatarPatterns.AsEnumerable()) .And().C("fv", "Size").Less().P("MaxSize") .Limit(1) .Build());

return await this.DbScope.Db .SetCommand(query, this.DbScope.Db.Parameter("PartnerID", partnerID, DataType.Guid), this.DbScope.Db.Parameter("MaxSize", MaxImageSizeInBytes, DataType.Int32)) .LogCommand() .ExecuteAsync<CardGetFileContentRequest>(cancellationToken); }

private async Task<bool> ExistsFileContentAsync( Guid partnerID, CancellationToken cancellationToken) { await using var _ = this.DbScope.Create(); var builderFactory = await this.DbScope.GetBuilderFactoryAsync(cancellationToken);

var query = builderFactory.Cached(this, "ExistsFile", static builder => builder .Select().Top(1).V(true) .From("Files", "f").NoLock() .InnerJoin("FileVersions", "fv").NoLock() .On().C("fv", "RowID").Equals().C("f", "VersionRowID") .Where().C("f", "ID").Equals().P("PartnerID") .And().LowerC("f", "Name").In(avatarPatterns.AsEnumerable()) .And().C("fv", "Size").Less().P("MaxSize") .Limit(1) .Build());

return await this.DbScope.Db .SetCommand(query, this.DbScope.Db.Parameter("PartnerID", partnerID, DataType.Guid), this.DbScope.Db.Parameter("MaxSize", MaxImageSizeInBytes, DataType.Int32)) .LogCommand() .ExecuteAsync<bool>(cancellationToken); }

#endregion } }

В объекте-репозитории можно описать дополнительную логику получения, добавления и удаления содержимого аватара, которая не описана в базовом объекте AvatarGeneratedContentRepository.

Далее в текущую папку необходимо добавить провайдер PartnerAvatarDefaultContentProvider:

PartnerAvatarDefaultContentProvider.cs

using System; using System.Linq; using System.Threading; using System.Threading.Tasks; using LinqToDB; using Tessa.Content.Avatars; using Tessa.Platform.Data;

namespace Tessa.Extensions.Server.Content { /// <summary> /// Предоставляет контент аватара контрагента по умолчанию. /// </summary> public interface IPartnerAvatarDefaultContentProvider : IAvatarDefaultContentProvider;

/// <inheritdoc cref="SvgAvatarDefaultContentProvider"/> public sealed class PartnerAvatarDefaultContentProvider(IDbScope dbScope) : SvgAvatarDefaultContentProvider(dbScope), IPartnerAvatarDefaultContentProvider { #region Base Overrides

/// <inheritdoc/> public override async Task<(string FirstName, string? LastName)> GetNameAsync( Guid id, AvatarContentKind kind, CancellationToken cancellationToken = default) { await using var _ = this.DbScope.Create(); var db = this.DbScope.Db; var builderFactory = await this.DbScope.GetBuilderFactoryAsync(cancellationToken);

var query = builderFactory.Cached(this, "GetPartnerName", static builder => builder .Select().C(null, "Name") .From("Partners").NoLock() .Where().C("ID").Equals().P("ID") .Build());

var name = await db .SetCommand(query, db.Parameter("ID", id, DataType.Guid)) .LogCommand() .ExecuteAsync<string>(cancellationToken);

return (name ?? "UNKNOWN", null); }

#endregion } }

В объекте-провайдере можно описать дополнительную логику генерации содержимого аватара по умолчанию, которая не описана в базовом объекте SvgAvatarDefaultContentProvider.

Далее в текущую папку необходимо добавить обработчик PartnerAvatarContentHandler для контента AbContentTypes.PartnerAvatar:

PartnerAvatarContentHandler.cs

using System; using System.Threading; using System.Threading.Tasks; using Tessa.Content; using Tessa.Content.Avatars; using Tessa.Extensions.Shared.Content; using Tessa.Platform.Data; using Tessa.Platform.Validation;

namespace Tessa.Extensions.Server.Content { /// <summary> /// Обработчик контента с типом <see cref="AbContentTypes.PartnerAvatar"/>. /// </summary> /// <param name="dbScope"><inheritdoc cref="IDbScope" path="/summary"/></param> /// <param name="tokenProvider"><inheritdoc cref="IPartnerAvatarContentTokenProvider" path="/summary"/></param> /// <param name="contentRepository"><inheritdoc cref="IPartnerAvatarContentRepository" path="/summary"/></param> /// <param name="defaultContentProvider"><inheritdoc cref="IPartnerAvatarDefaultContentProvider" path="/summary"/></param> /// <param name="contentPermissionsManager"><inheritdoc cref="IPartnerAvatarContentPermissionsManager" path="/summary"/></param> /// <param name="tokenPermissionsManager"><inheritdoc cref="IAvatarContentTokenPermissionsManager" path="/summary"/></param> public sealed class PartnerAvatarContentHandler( IDbScope dbScope, IPartnerAvatarContentTokenProvider tokenProvider, IPartnerAvatarContentRepository contentRepository, IPartnerAvatarDefaultContentProvider defaultContentProvider, IPartnerAvatarContentPermissionsManager contentPermissionsManager, IAvatarContentTokenPermissionsManager tokenPermissionsManager) : AvatarContentHandler( dbScope, tokenProvider, contentRepository, defaultContentProvider, contentPermissionsManager, tokenPermissionsManager) { #region Private Fields

private static readonly int maxCacheSeconds = (int) TimeSpan.FromDays(2.0).TotalSeconds;

#endregion

#region Base Override

/// <inheritdoc/> protected override string Scope => AbContentTypes.PartnerAvatar;

/// <inheritdoc/> protected override int MaxCacheSeconds => maxCacheSeconds;

/// <inheritdoc/> public override async Task<ContentResponse> GetContentAsync( ContentRequest request, CancellationToken cancellationToken = default) { try { return await base.GetContentAsync(request, cancellationToken); } catch (ValidationException ex) { return new(new ValidationResultBuilder { ex.Result }); } }

#endregion } }

В объекте-обработчике можно описать дополнительную логику для получения токена и содержимого аватара, которая не описана в базовом объекте AvatarContentHandler.

Класс регистратора Registrator должен быть добавлен в ту же папку:

Registrator.cs

using Tessa.Content; using Tessa.Extensions.Shared.Content; using Tessa.Tokens; using Unity; using Unity.Lifetime;

namespace Tessa.Extensions.Server.Content { [Registrator] public sealed class Registrator : RegistratorBase { public override void RegisterUnity() => NotNullOrThrow(this.UnityContainer) .RegisterSingleton<IPartnerAvatarContentTokenProvider, PartnerAvatarContentTokenProvider>() .RegisterSingleton<IPartnerAvatarContentPermissionsManager, PartnerAvatarContentPermissionsManager>() .RegisterSingleton<IPartnerAvatarDefaultContentProvider, PartnerAvatarDefaultContentProvider>() .RegisterSingleton<IPartnerAvatarContentRepository, PartnerAvatarContentRepository>() .RegisterSingleton<PartnerAvatarContentHandler>(AbContentTypes.PartnerAvatar) .RegisterFactory<IAccessTokenProvider>(AbContentTypes.PartnerAvatar, static c => c.Resolve<PartnerAvatarContentHandler>(), new ContainerControlledLifetimeManager()) .RegisterFactory<IContentProvider>(AbContentTypes.PartnerAvatar, static c => c.Resolve<PartnerAvatarContentHandler>(), new ContainerControlledLifetimeManager()); } }

Important

Регистрация обработчика обязательно должна быть именованной, где имя соответствует уникальному названию типа контента.

  • В данном примере используется наследование классов для реализации провайдеров, менеджеров и репозиториев, поскольку основная логика работы с токенами и аватарами во многом схожа. Однако на практике часто возникает необходимость реализовывать значительную часть логики самостоятельно. В таких случаях рекомендуется создавать независимые объекты, не зависящие от платформенных классов, и переопределять соответствующие зависимости через регистратор.

  • Для часто запрашиваемого контента можно добавить кэширование в памяти процесса (для кэширования можно использовать ICardCache.Settings).

  • Если необходимо обеспечить дополнительное кэширование токенов в памяти или объектов, связанных с токенами, то используйте объект ITokenCache. Подробнее в разделе Объекты API для взаимодействия с токенами доступа.

Так как доступ к аватарам контрагентов может быть частым, токен можно передать на клиент при инициализации, не дожидаясь, пока клиент сам запросит токен.

В проекте Tessa.Extensions.Server необходимо создать папку Initialization, в которой будет размещено расширение PartnerAvatarContentTokenServerInitializationExtension:

PartnerAvatarContentTokenServerInitializationExtension.cs

using System.Threading.Tasks; using NLog; using Tessa.Content.Avatars; using Tessa.Extensions.Server.Content; using Tessa.Platform; using Tessa.Platform.Initialization; using Tessa.Platform.Runtime; using Tessa.Platform.Storage; using Tessa.Platform.Validation; using Tessa.Tokens;

namespace Tessa.Extensions.Server.Initialization { public sealed class PartnerAvatarContentTokenServerInitializationExtension( IPartnerAvatarContentTokenProvider tokenProvider, ISession session) : ServerInitializationExtension { #region Fields

private readonly IPartnerAvatarContentTokenProvider tokenProvider = NotNullOrThrow(tokenProvider); private readonly ISession session = NotNullOrThrow(session);

private static readonly Logger logger = LogManager.GetCurrentClassLogger();

#endregion

#region Base Overrides

public override async Task AfterRequest(IServerInitializationExtensionContext context) { if (!context.RequestIsSuccessful) { return; }

var validationResult = new ValidationResultBuilder(); var tokenRequest = new AvatarContentTokenRequest(this.session.User.ID); var token = await this.tokenProvider.GetOrCreateTokenAsync(tokenRequest, validationResult, context.CancellationToken);

logger.LogResult(validationResult);

context.Response!.Info["PartnerAvatarContentToken"] = token?.ToAccessToken().ToSerializedDictionary(); }

#endregion } }

Класс регистратора Registrator должен быть добавлен в ту же папку:

Registrator.cs

using Tessa.Platform.Initialization; using Tessa.Platform.Runtime; using Unity;

namespace Tessa.Extensions.Server.Initialization { [Registrator] public sealed class Registrator : RegistratorBase { public override void RegisterUnity() => this.UnityContainer .RegisterSingleton<PartnerAvatarContentTokenServerInitializationExtension>();

public override void RegisterExtensions(IExtensionContainer extensionContainer) => NotNullOrThrow(extensionContainer) .RegisterExtension<IServerInitializationExtension, PartnerAvatarContentTokenServerInitializationExtension>(x => x .WithOrder(ExtensionStage.AfterPlatform, 1) .WithUnity(this.UnityContainer) .WhenApplications(ApplicationIdentifiers.WebClient)); } }

Расширения web-клиента для использования ссылок на контент

Реализуем клиентскую логику для отображения аватаров контрагентов.

В папке проектных расширений web-клиента solution (в сборке она расположена в WebClient SDK/src/solution) необходимо создать папку content. В неё следует добавить файл abContentTypes.ts с константой PartnerAvatar, аналогичной реализации в C# AbContentTypes.cs:

abContentTypes.ts

/** * Типы контента. * @helper */ export namespace AbContentTypes { /** Тип контента для аватаров контрагентов. */ export const partnerAvatar = 'partner-avatars'; }

В эту же папку нужно добавить файл contentTypes.ts, который будет содержать описание типов:

contentTypes.ts

import { IAvatarManager, IAvatarTokenManager } from '@tessa/platform';

/** Manages partner avatar tokens operations. */ export interface IPartnerAvatarTokenManager extends IAvatarTokenManager {}

/** Manages common avatar operations. */ export interface IPartnerAvatarManager extends IAvatarManager {}

Теперь необходимо добавить менеджер токенов PartnerAvatarTokenManager:

partnerAvatarTokenManager.ts

import { TypedField } from '@tessa/core'; import { ContentTokenRequest } from '@tessa/platform'; import { inject, injectable, ISession, ISession$ } from '@tessa/application'; import { IContentService, IContentService$, AvatarTokenManager } from '@tessa/platform'; import { IPartnerAvatarTokenManager } from './contentTypes'; import { AbContentTypes } from './abContentTypes';

@injectable() export class PartnerAvatarTokenManager extends AvatarTokenManager implements IPartnerAvatarTokenManager { //#region constructors

constructor( @inject(IContentService$) contentService: IContentService, @inject(ISession$) session: ISession ) { super(contentService, session); }

//#endregion

//#region base overrides

protected override get contentType(): string { return AbContentTypes.partnerAvatar; }

protected override getTokenRequest(forceReissue = false): ContentTokenRequest { const request = super.getTokenRequest(forceReissue); request.info['AdditionalInfo'] = TypedField.createNewGuid(); return request; }

//#endregion }

Данный менеджер обеспечивает хранение пользовательского токена, кроме этого он запрашивает новый токен, когда срок действия текущего подходит к концу. Функциональность менеджера можно расширить при необходимости.

Далее необходимо добавить менеджер аватаров PartnerAvatarManager:

partnerAvatarManager.ts

import { ValidationResult } from '@tessa/core'; import { IApiClient, IApiClient$, inject, injectable } from '@tessa/application'; import { IAvatarService$, IAvatarTokenManager$, ICardService$ } from '@tessa/platform'; import { AvatarContentKind, AvatarManager, IAvatarService, ICardService } from '@tessa/platform'; import { IPartnerAvatarManager, IPartnerAvatarTokenManager } from './contentTypes'; import { AbContentTypes } from './abContentTypes';

@injectable() export class PartnerAvatarManager extends AvatarManager implements IPartnerAvatarManager { //#region constructors

constructor( @inject(IAvatarService$) avatarService: IAvatarService, @inject(IAvatarTokenManager$) avatarTokenManager: IPartnerAvatarTokenManager, @inject(ICardService$) cardService: ICardService, @inject(IApiClient$) apiClient: IApiClient ) { super(avatarService, avatarTokenManager, cardService, apiClient); }

//#endregion

//#region base overrides

protected override get contentType(): string { return AbContentTypes.partnerAvatar; }

override resetAvatar(_id: string, _kind: AvatarContentKind): Promise<ValidationResult> { throw new Error('Method not implemented.'); }

//#endregion }

Данный менеджер формирует ссылку на аватар контрагента, используя токен из IPartnerAvatarTokenManager. Функциональность менеджера можно расширить при необходимости.

Оба менеджера должны быть зарегистрированы в DI-контейнере, для этого сначала необходимо добавить файл contentInjects.ts:

contentInjects.ts

import { createInjectToken } from '@tessa/application'; import { IPartnerAvatarManager, IPartnerAvatarTokenManager } from './contentTypes';

/** @category injects */ export const IPartnerAvatarTokenManager$ = createInjectToken<IPartnerAvatarTokenManager>( 'IPartnerAvatarTokenManager' );

/** @category injects */ export const IPartnerAvatarManager$ = createInjectToken<IPartnerAvatarManager>('IPartnerAvatarManager');

А после регистратор abContentRegistrator.ts:

abContentRegistrator.ts

import { ExtensionRegistrator } from '@tessa/application'; import { PartnerAvatarManager } from './partnerAvatarManager'; import { PartnerAvatarTokenManager } from './partnerAvatarTokenManager'; import { IPartnerAvatarManager$, IPartnerAvatarTokenManager$ } from './contentInjects';

export const AbContentRegistrator: ExtensionRegistrator = { async registerTypes(container) { container.bind(IPartnerAvatarManager$).to(PartnerAvatarManager).inSingletonScope(); container.bind(IPartnerAvatarTokenManager$).to(PartnerAvatarTokenManager).inSingletonScope(); } };

Регистратор следует добавить в bundleRegistrator.ts.

Для удобства добавления аватара и получения ссылки на аватар добавим вспомогательный метод. Для этого создадим файл abContentLinks.ts в текущей папке content:

abContentLinks.ts

import { ValidationResult } from '@tessa/core'; import { AvatarContentKind } from '@tessa/platform'; import { IPartnerAvatarManager$ } from './contentInjects';

/** * @helper */ export namespace AbContentLinks { /** * Stores partner avatar content, after that reissues current token in order to update links. * @param content Content of the new avatar. * @param partnerId Partner identifier. */ export async function storePartnerAvatarLink( content: File | Blob, partnerId: string ): Promise<ValidationResult> { const manager = window.tessa.diContainer.get(IPartnerAvatarManager$); return await manager.storeAvatar(partnerId, AvatarContentKind.Avatar, content); }

/** * Returns partner avatar link for partner with identifier {@link partnerId}. * @param partnerId Partner identifier. */ export async function getPartnerAvatarLink( partnerId: string ): Promise<[string | null, ValidationResult]> { const manager = window.tessa.diContainer.get(IPartnerAvatarManager$); return await manager.getAvatarLink(partnerId, AvatarContentKind.Avatar); } }

Так как токен передаётся при инициализации с сервера, реализуем расширение для его получения и установки на клиенте. В папке проектных расширений web-клиента solution необходимо создать папку initialization и добавить файл partnerAvatarContentTokenInitializationExtension.ts:

partnerAvatarContentTokenInitializationExtension.ts

import { AccessTokenInfo, IInitializationExtensionContext, InitializationExtension } from '@tessa/platform'; import { extension, inject } from '@tessa/application'; import { StorageAccessor } from '@tessa/core'; import { IPartnerAvatarTokenManager$ } from '../content/contentInjects'; import { IPartnerAvatarTokenManager } from '../content/contentTypes';

@extension({ name: 'PartnerAvatarContentTokenInitializationExtension' }) export class PartnerAvatarContentTokenInitializationExtension extends InitializationExtension { //#region constructors

constructor( @inject(IPartnerAvatarTokenManager$) private readonly _tokenManager: IPartnerAvatarTokenManager ) { super(); }

//#endregion

//#region base overrides

public override async afterRequest(context: IInitializationExtensionContext): Promise<void> { const info = context.response?.tryGetInfo(); if (!info) { return; }

const storageAccessor = new StorageAccessor(info); const tokenInfo = storageAccessor.tryGetObject('PartnerAvatarContentToken', storage => new AccessTokenInfo().deserializeFromStorage(storage) );

if (tokenInfo) { await this._tokenManager.setTokenInfo(tokenInfo); } }

//#endregion }

Таким образом, при первом запросе ссылки на аватар контрагента токен уже будет храниться на клиенте, и его можно сразу использовать.

Необходимо добавить файл регистратора abInitializationRegistrator.ts в этой же папке:

abInitializationRegistrator.ts

import { ExtensionRegistrator, ExtensionStage } from '@tessa/application'; import { PartnerAvatarContentTokenInitializationExtension } from './partnerAvatarContentTokenInitializationExtension';

export const AbInitializationRegistrator: ExtensionRegistrator = { async registerTypes() {}, async registerExtensions(container) { container.registerExtension({ extension: PartnerAvatarContentTokenInitializationExtension, stage: ExtensionStage.AfterPlatform, singleton: true }); } };

Регистратор следует добавить в bundleRegistrator.ts.

Для демонстрации применения ссылок на аватары контрагентов создадим расширение на узел рабочего места, которое будет добавлять в ячейку представления “Контрагенты” аватар контрагента, указанного в строке представления.

Для этого в папке проектных расширений web-клиента solution необходимо создать папку views и добавить файл partnerAvatarViewExtension.tsx:

partnerAvatarViewExtension.tsx

import React, { FC, useEffect, useState } from 'react'; import { extension } from '@tessa/application'; import { TableGridViewModelBase } from 'tessa/ui/views/content'; import { WorkplaceViewComponentExtension } from 'tessa/ui/views/extensions'; import { IWorkplaceViewComponent, StandardViewComponentContentItemFactory } from 'tessa/ui/views'; import { AbContentLinks } from '../content/abContentLinks';

@extension({ name: 'PartnerAvatarViewExtension' }) export class PartnerAvatarViewExtension extends WorkplaceViewComponentExtension { public getExtensionName(): string { return 'PartnerAvatarViewExtension'; }

// расширение выполняется только для представления "Контрагенты" public shouldExecute(model: IWorkplaceViewComponent): boolean { return model.getViewMetadata(model)?.alias === 'Partners'; }

public initialize(model: IWorkplaceViewComponent): void { const tableFactory = model.contentFactories.get(StandardViewComponentContentItemFactory.Table); if (!tableFactory) { return; }

model.contentFactories.set(StandardViewComponentContentItemFactory.Table, c => { const vm = tableFactory(c); if (vm instanceof TableGridViewModelBase) { // переопределим метод создания ячейки представления const initAction = vm.createCellAction; vm.createCellAction = options => { const cell = initAction(options); // добавляем аватар в ячейку "Краткое наименование" if ( options.column.columnName === 'PartnerName' && (!options.value || typeof options.value === 'string') ) { // получаем идентификатор контрагента из соответствующей колонки представления const partnerId = options.row.data.get('PartnerID'); if (partnerId && typeof partnerId === 'string') { // переопределяем метод получения контента ячейки на кастомный компонент cell.getContent = () => ( <PartnerAvatar partnerId={partnerId} partnerName={options.value} /> ); } }

return cell; }; }

return vm; }); } }

// Компонент для отображения аватара и наименования контрагента const PartnerAvatar: FC<{ partnerId: string; partnerName: string }> = ({ partnerId, partnerName }) => { const [partnerLink, setPartnerLink] = useState<string | null>(null);

// асинхронно получаем ссылку на аватар useEffect(() => { AbContentLinks.getPartnerAvatarLink(partnerId).then(([link]) => { setPartnerLink(link); }); }, [partnerId]);

if (!partnerLink) { return <span>{partnerName}</span>; }

return ( <div style={{ display: 'flex', gap: '5px', alignItems: 'center' }}> <img src={partnerLink} width="44" height="44" style={{ borderRadius: '100%' }} /> <span>{partnerName}</span> </div> ); };

После этого необходимо добавить в эту же папку регистратор abViewsRegistrator.ts:

abViewsRegistrator.ts

import { ExtensionRegistrator, ExtensionStage } from '@tessa/application'; import { PartnerAvatarViewExtension } from './partnerAvatarViewExtension';

export const AbViewsRegistrator: ExtensionRegistrator = { async registerTypes() {}, async registerExtensions(container) { container.registerExtension({ extension: PartnerAvatarViewExtension, stage: ExtensionStage.AfterPlatform, singleton: true }); } };

Регистратор следует добавить в bundleRegistrator.ts.

Далее необходимо добавить кнопку для возможности установки аватара из карточки контрагента. Для этого в папке проектных расширений web-клиента solution необходимо создать папку tiles и добавить файл partnerAvatarTileExtension.ts:

partnerAvatarTileExtension.ts

import { Guid } from '@tessa/core'; import { AvatarContentKind } from '@tessa/platform'; import { extension, inject, ISession, ISession$ } from '@tessa/application'; import { TileExtension, ITileLocalExtensionContext, Tile, TileGroups } from 'tessa/ui/tiles'; import { showAvatarSelectionDialog } from 'tessa/ui/tessaDialog/avatarSelection/showAvatarSelectionDialog'; import { IPartnerAvatarManager$ } from '../content/contentInjects'; import { IPartnerAvatarManager } from '../content/contentTypes';

@extension({ name: 'PartnerAvatarTileExtension' }) export class PartnerAvatarTileExtension extends TileExtension { constructor( @inject(ISession$) private readonly _session: ISession, @inject(IPartnerAvatarManager$) private readonly _avatarManager: IPartnerAvatarManager ) { super(); }

override initializingLocal(context: ITileLocalExtensionContext): void { const panel = context.workspace.leftPanel; panel.tiles.push( new Tile({ name: 'ChangePartnerAvatar', caption: 'Изменить аватар', icon: 'm-emoji', contextSource: panel.contextSource, group: TileGroups.CardsTop, order: 10, command: async e => { await showAvatarSelectionDialog({ id: e.context.cardEditor!.cardModel!.card.id, kind: AvatarContentKind.Avatar, avatarManager: this._avatarManager }); }, evaluating: e => { e.setIsEnabledWithCollapsing( e.currentTile, this._session.user.isAdmin && Guid.equals( e.current.context.cardEditor?.cardModel?.cardType.id, 'b9a1f125-ab1d-4cff-929f-5ad8351bda4f' // PartnerTypeID ) ); } }) ); } }

После этого необходимо добавить в эту же папку регистратор abTilesRegistrator.ts:

abTilesRegistrator.ts

import { ExtensionRegistrator, ExtensionStage } from '@tessa/application'; import { PartnerAvatarTileExtension } from './partnerAvatarTileExtension';

export const AbTilesRegistrator: ExtensionRegistrator = { async registerTypes() {}, async registerExtensions(container) { container.registerExtension({ extension: PartnerAvatarTileExtension, stage: ExtensionStage.AfterPlatform, singleton: true }); } };

Регистратор следует добавить в bundleRegistrator.ts.

После компиляции WebClient SDK и публикации клиентских расширений на сервере можно убедиться, что аватар контрагента доступен по ссылке и может быть отображен в web-клиенте. Кроме этого, ссылку можно скопировать и использовать в любом другом месте, контент будет доступен, пока не истек срок действия токена.

Контент, который используется на клиенте и часто им запрашивается (например, аватары пользователей), целесообразно кэшировать на стороне браузера, чтобы снизить нагрузку на сервер и обеспечить максимально быстрое отображение для пользователя.

Для этого при возврате контента в возвращаемом результате метода IContentProvider.GetContentAsync в параметре CacheOptions можно указать количество секунд, в течение которых контент может быть получен из кэша браузера. Если этот параметр указан и отличен от 0, в заголовки ответа будет добавлен заголовок Cache-Control с указанным значением. При этом стоит учитывать следующее:

  • Кэширование происходит по ссылке. Пока ссылка на контент остаётся неизменной, контент может быть получен из кэша.
  • Единственным изменяемым параметром в ссылке на контент, который уникально определяется скоупом и идентификатором контента, является токен.
  • На период действия, указанный в CacheOptions, контент будет браться из кэша, даже несмотря на то, что сам контент на сервере может быть изменён, так как ссылка на контент изменяется только с изменением токена.
  • Если необходимо, чтобы клиент получил обновлённый контент сразу, то нужно принудительно сбросить текущий токен и создать новый (в примере выше при запросе токена для этого использовался параметр ForceReissue), после чего переформировать ссылку с новым токеном.
  • Стоит учитывать, что если токен является уникальным в рамках пользователя и скоупа, то токен указывается в ссылках на все ресурсы в рамках этого скоупа, соответственно, при изменении токена обновятся все ссылки для этого скоупа, и браузер будет вынужден запросить контент по всем ссылкам заново.
  • Выбор срока действия токена для кэшируемого контента является компромиссом между производительностью и сроком получения обновлённого контента.
  • Ссылка на контент формируется вручную. Если ссылка формируется на клиенте, то необходимо отслеживать срок действия токена и получать новый токен по истечении срока действия текущего.
  • Если контент закэширован браузером, то он может быть получен пользователем, даже если указанный в ссылке токен был сброшен.

Мониторинг запросов получения контента по ссылке

Для запросов получения контента по ссылке в платформе реализованы счётчики производительности и источник данных для трассировки (подробнее в разделе Веб-сервис Monitor для диагностики и трассировки).

Список счётчиков производительности:

  • total-content-by-link-requests - количество запросов на получение контента по ссылке.
  • total-content-by-link-requests-duration - суммарное время выполнения запросов на получение контента по ссылке в миллисекундах.
  • current-content-by-link-requests - количество запросов на получение контента по ссылке в данный момент.

Все счётчики имеют параметр ContentType, в котором указан тип запрашиваемого контента.

Чтобы отследить среднее время выполнения запросов за некоторый период (например, 5 минут), можно использовать следующее выражение:
rate(total-content-by-link-requests-duration[5m]) / rate(total-content-by-link-requests[5m]).

Для трассировки запросов доступен источник трассировки GetContentByLink, который предоставляет специфичные для контента тэги:

  • ContentType - тип запрашиваемого контента.
  • Token - замаскированный токе доступа, который был использован для получения контента.

Пример результата трассировки получения контента аватара по ссылке:

Back to top