Временные ссылки на контент системы¶
Серверная реализация обработчика контента в проектном решении¶
Рассмотрим пример создания обработчика контента в рамках проектного решения:
- Контентом в данном примере будут аватары контрагентов (фото не учитывается).
- Ограничим время действия токена тремя днями, а время кэширования контента двумя днями.
- Как и в случае аватаров пользователей, токен будет привязан к пользователю.
- Будем полагать, что файлы аватаров:
- могут быть установлены из карточки контрагента с помощью разработанной кнопки “Установить аватар”;
- могут прикрепляться к карточке контрагента с названием
"avatar.jpg|jpeg", и размер такого файла не превышает 20 КиБ.
- Если аватар для контрагента не задан, то формируется стандартный аватар в формате
svgс инициалами контрагента.
Обработка ссылок на контент осуществляется на уровне платформы. В серверной части проектного решения необходимо:
- Определить тип для контента (не требует регистрации).
- Реализовать интерфейсы провайдеров
IAccessTokenProviderиIContentProvider, которые содержат всю необходимую логику по созданию токена и возврату запрашиваемого контента. - Зарегистрировать обработчик в контейнере 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- замаскированный токе доступа, который был использован для получения контента.
Пример результата трассировки получения контента аватара по ссылке:
