Перейти к основному содержимому

Процесс получения и обработки активностей в Scount

Версия: 1.0 Дата: 2026-01-28 Сервис: Scount


Оглавление

  1. Обзор системы
  2. Доменная модель Activity
  3. Типы активностей
  4. Состояния активности
  5. Процесс получения активностей
  6. Команды для работы с активностями
  7. Обработчики доменных событий
  8. Расчет вознаграждения
  9. Интеграции с внешними сервисами
  10. Queries для получения активностей
  11. Схема полного процесса

Обзор системы

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

Основные компоненты:

  • Activity (агрегат) - доменная модель активности
  • Commands - команды для создания и изменения активностей
  • Subscription Handlers - обработчики внешних и внутренних событий
  • Activities Service - сервис для валидации, расчета вознаграждений и поиска подходящих заданий
  • Callback Controllers - API endpoints для получения активностей от внешних систем

Схемы обработки

В системе существует две схемы обработки активностей:

Новая схема (IsNewScheme = true):

  • Автоматическая обработка полностью внутри Scount
  • После создания активности автоматически рассчитывается вознаграждение
  • Активность автоматически применяется к подходящему заданию участника
  • При применении активности задание автоматически завершается
  • Вознаграждение начисляется на баланс участника

Старая схема (IsNewScheme = false):

  • Обработка через внешние Task Managers (QuizesTaskManager, GeoActivitiesTaskManager и др.)
  • Внешние системы получают события через Kafka и управляют применением активностей
  • Завершение заданий управляется внешними Task Managers

Определение схемы: Схема определяется настройкой маркетинговой программы через IMarketingProgramService.IsNewSchemeByMarketingProgram() или IsNewSchemeByActivity().


Доменная модель Activity

Агрегат Activity

Файл: src/Scount.Domain/Activities/Activity.cs

public sealed class Activity : AggregateRoot<Guid, ActivityId>
{
// Основные свойства
public ParticipantId ParticipantId { get; private set; }
public MarketingProgramId MarketingProgramId { get; private set; }
public ParticipantExerciseTaskId? ParticipantExerciseTaskId { get; private set; }
public ActivityData Data { get; private set; }
public ActivityType Type { get; private set; }
public ActivityState State { get; private set; }
public Reward? Reward { get; private set; }
public DateTime CreatedOn { get; private set; }
}

Свойства:

  • ParticipantId - ID участника, выполнившего активность
  • MarketingProgramId - ID маркетинговой программы
  • ParticipantExerciseTaskId - ID задания, к которому применена активность (если применена)
  • Data - данные активности (Dictionary<string, string>)
  • Type - тип активности
  • State - текущее состояние активности
  • Reward - вознаграждение за активность (если рассчитано)
  • CreatedOn - дата создания

ActivityData

Файл: src/Scount.Domain/Activities/ActivityData.cs

public sealed class ActivityData : Dictionary<string, string>
{
public ActivityData() : base() { }
public ActivityData(IDictionary<string, string> data) : base(data) { }
}

Словарь с произвольными данными активности. Структура данных зависит от типа активности.

Пример для SportActivity:

{
"TrackerId": "guid",
"Provider": "Strava",
"Discipline": "running",
"StartDate": "2026-01-28T10:00:00Z",
"FinishDate": "2026-01-28T11:00:00Z",
"Distance": "5000",
"TimeInSeconds": "1800",
"AveragePace": "6.0",
"AverageSpeed": "10.0",
"IsManual": "false",
"RefereeingSystemCheckState": "Accepted",
"IsCyclic": "true",
"Intensity": "0.75"
}

Доменные методы Activity

UpdateData

public void UpdateData(ActivityData data)

Обновляет данные активности. Генерирует событие ActivityDataUpdated.

Apply

public void Apply(ParticipantExerciseTaskId participantExerciseTaskId)

Применяет активность к заданию участника. Переводит состояние в Applied. Генерирует событие ActivityApplied.

Skip

public void Skip()

Пропускает активность (если не удалось найти подходящее задание). Переводит состояние в Skipped. Генерирует событие ActivitySkipped.

SetReward

public void SetReward(Reward reward)

Устанавливает вознаграждение за активность. Генерирует событие ActivityRewardSet.

Доменные события

Расположение: src/Scount.Domain/Activities/Events/

  1. ActivityCreated - активность создана
  2. ActivityDataUpdated - данные активности обновлены
  3. ActivityApplied - активность применена к заданию
  4. ActivitySkipped - активность пропущена
  5. ActivityRewardSet - вознаграждение установлено

Типы активностей

Файл: src/Scount.Domain/Activities/ActivityType.cs

public enum Types
{
SportActivity, // Спортивная активность (циклическая)
SporadicActivity, // Спорадическая активность (нециклическая)
QuizActivity, // Активность прохождения квиза
InviteActivity, // Активность приглашения
PartnersActivity, // Активность партнеров
GeoLocationActivity, // Геолокационная активность
RegistrationActivity, // Активность регистрации
ParticipatingActivity, // Активность участия
UploadImageActivity // Активность загрузки изображения
}

Каждый тип активности имеет:

  • Свою структуру данных (ActivityData)
  • Свою логику валидации
  • Свой способ расчета вознаграждения
  • Свой способ поиска подходящего задания

Состояния активности

Файл: src/Scount.Domain/Activities/ActivityState.cs

public enum States
{
Created, // Создана, но не применена
Applied, // Применена к заданию участника
Skipped // Пропущена (не найдено подходящее задание)
}

Диаграмма переходов состояний

┌─────────┐
│ Created │
└────┬────┘

├──────────────┐
│ │
v v
┌─────────┐ ┌─────────┐
│ Applied │ │ Skipped │
└─────────┘ └─────────┘

Правила переходов:

  • Created → Applied - когда найдено подходящее задание и активность применена
  • Created → Skipped - когда не найдено подходящее задание
  • Applied → Skipped - недопустимо (выбрасывается InvalidOperationException)
  • Skipped → Applied - недопустимо

Процесс получения активностей

1. Источники активностей

1.1 Callback от внешних сервисов (SportActivities, SporadicActivities)

Контроллеры:

  • SportActivitiesCallbackController (Host: Scount.Callback.Host)
  • SporadicActivitiesCallbackController (Host: Scount.Callback.Host)

Endpoints:

POST /api/v{version}/sport-activities-callback/activity-accepted
POST /api/v{version}/sport-activities-callback/activity-rejected

POST /api/v{version}/sporadic-activities-callback/activity-accepted
POST /api/v{version}/sporadic-activities-callback/activity-rejected

Файл: src/Hosts/Scount.Callback.Host/Controllers/Api/SportActivitiesCallbackController.cs

Пример обработки callback:

[HttpPost("activity-accepted")]
public async Task<IActionResult> ActivityAcceptedCallbackAsync(
[FromBody] ActivityCallbackBinding binding,
CancellationToken cancellationToken = default)
{
var callback = binding.Body;

// Проверяем, существует ли активность
var isActivityExists = await _readModelQueryExecutor.AnyAsync(
_readModel.Activities.Where(a => a.ActivityId == callback.ActivityId),
cancellationToken);

var activityData = BindingToActivityData(binding.Body, "Accepted");

if (!isActivityExists)
{
// Создаем новую активность
result = await _commandExecutor.Execute(
new CreateActivityCommand(
ActivityId.With(callback.ActivityId),
ParticipantId.Parse(callback.UserId),
new ActivityData(activityData),
ActivityType.SportActivity),
cancellationToken);
}
else
{
// Обновляем существующую
result = await _commandExecutor.Execute(
new UpdateActivityDataCommand(
ActivityId.With(callback.ActivityId),
new ActivityData(activityData),
ActivityType.SportActivity),
cancellationToken);
}

return result;
}

1.2 События от внешних сервисов (Kafka)

Обработчик: ActivityCreated.Handler в Subscriptions/__External/SportActivities/

Файл: src/Scount.Application/Subscriptions/__External/SportActivities/ActivityCreated/ActivityCreated.Handler.cs

public async Task Handle(ActivityCreated @event, CancellationToken cancellationToken)
{
var activityId = Guid.Parse(@event.AggregateId);
var participantId = @event.ParticipantId;
var activityData = ToActivityData(@event);

await _commandExecutor.Execute(
new CreateActivityCommand(
ActivityId.With(activityId),
ParticipantId.With(participantId),
new ActivityData(activityData),
@event.IsCyclic ? ActivityType.SportActivity : ActivityType.SporadicActivity),
cancellationToken);
}

2. Создание активности

Команда: CreateActivityCommand

Файлы:

  • src/Scount.Application/Commands/Activities/CreateActivityCommand/CreateActivityCommand.cs
  • src/Scount.Application/Commands/Activities/CreateActivityCommand/CreateActivityCommand.Handler.cs

Процесс:

public async Task<OneOf<...>> Handle(CreateActivityCommand request, CancellationToken cancellationToken)
{
// 1. Проверяем, не существует ли активность
var activity = await _activitiesRepository.FindById(request.ActivityId, cancellationToken);
if (activity != null)
{
// Идемпотентность: проверяем совпадение данных
if (activity.ParticipantId != request.ParticipantId ||
!activity.Data.SequenceEqual(request.Data) ||
activity.Type != request.Type)
return Conflict("Activity already exists.");

return Success();
}

// 2. Валидация данных активности
try
{
_activitiesService.ValidateActivityData(request.Type, request.Data);
}
catch (ActivityDataValidationException ex)
{
return InvalidOperation(ex.Message);
}

// 3. Проверяем существование участника
var participant = await _participantsRepository.FindById(request.ParticipantId, cancellationToken);
if (participant == null)
return ParticipantNotFound("Participant not found.");

// 4. Создаем активность через агрегат Participant
try
{
activity = participant.CreateActivity(request.ActivityId, request.Data, request.Type);
}
catch (ParticipantBlockedException e)
{
return ParticipantBlocked(e.Message);
}

// 5. Сохраняем
await _activitiesRepository.Save(activity);

return Success();
}

Важно: Активность создается через метод Participant.CreateActivity(), который проверяет, не заблокирован ли участник.


Команды для работы с активностями

CreateActivityCommand

Назначение: Создание новой активности

Параметры:

  • ActivityId - ID активности
  • ParticipantId - ID участника
  • ActivityData - данные активности
  • ActivityType - тип активности

Результаты:

  • SuccessResult - успешно создано
  • ParticipantNotFoundResult - участник не найден
  • ParticipantBlockedResult - участник заблокирован
  • InvalidOperationResult - ошибка валидации данных
  • ConflictResult - активность уже существует с другими данными

UpdateActivityDataCommand

Назначение: Обновление данных существующей активности

Параметры:

  • ActivityId - ID активности
  • ActivityData - новые данные
  • ActivityType - тип активности

Результаты:

  • SuccessResult
  • NotFoundResult
  • InvalidOperationResult
  • ConflictResult

ApplyActivityCommand

Назначение: Применение активности к заданию участника

Параметры:

  • ActivityId - ID активности
  • ParticipantExerciseTaskId? - ID задания (может быть null, тогда ищется автоматически)

Файл: src/Scount.Application/Commands/Activities/ApplyActivityCommand/ApplyActivityCommand.Handler.cs

Процесс:

public async Task<OneOf<...>> Handle(ApplyActivityCommand request, CancellationToken cancellationToken)
{
// 1. Получаем активность
var activity = await _activitiesRepository.FindById(request.ActivityId, cancellationToken);
if (activity == null)
return NotFound("Activity not found.");

// 2. Проверяем идемпотентность
if (activity.State == ActivityState.Applied)
{
if (activity.ParticipantExerciseTaskId != request.ParticipantExerciseTaskId)
return InvalidOperation("Activity already applied to another task");
return Success();
}

// 3. Определяем ParticipantExerciseTaskId
var participantExerciseTaskId = request.ParticipantExerciseTaskId
?? await _activitiesService.GetSuitableParticipantExerciseTaskIdAsync(activity, cancellationToken);

if (participantExerciseTaskId == null)
return SuitableParticipantExerciseTaskNotFound("No suitable task found.");

// 4. Проверяем, не применена ли другая активность к этому заданию
if (await _activitiesRepository.AnyAnotherAsync(participantExerciseTaskId, activity.Id, cancellationToken))
return Conflict("Task already has another activity.");

// 5. Применяем активность
activity.Apply(participantExerciseTaskId);

// 6. Сохраняем
await _activitiesRepository.Save(activity);

return Success();
}

SkipActivityCommand

Назначение: Пропуск активности (если не найдено подходящее задание)

Параметры:

  • ActivityId - ID активности

CalculateRewardCommand

Назначение: Расчет вознаграждения за активность

Параметры:

  • ActivityId - ID активности

Файл: src/Scount.Application/Commands/Activities/CalculateRewardCommand/CalculateRewardCommand.Handler.cs

Процесс:

public async Task<OneOf<...>> Handle(CalculateRewardCommand request, CancellationToken cancellationToken)
{
// 1. Получаем активность
var activity = await _activitiesRepository.FindById(request.ActivityId, cancellationToken);
if (activity == null)
return NotFound("Activity not found.");

// 2. Рассчитываем вознаграждение через сервис
var reward = await _activitiesService.CalculateRewardAsync(
activity.MarketingProgramId,
activity.Type,
activity.Data,
cancellationToken);

if (reward == null)
return Success(); // Вознаграждение не предусмотрено

// 3. Округляем до целого числа
var roundedReward = Math.Round(reward.Value, 0, MidpointRounding.AwayFromZero);

// 4. Устанавливаем вознаграждение
activity.SetReward(Reward.With(roundedReward));

// 5. Сохраняем
await _activitiesRepository.Save(activity);

return Success();
}

Обработчики доменных событий

ActivityCreated

Обработчик: ActivityCreated.Handler в Subscriptions/Activities/

Файл: src/Scount.Application/Subscriptions/Activities/ActivityCreated/ActivityCreated.Handler.cs

Назначение: Автоматическая обработка новой активности (только для новой схемы расчетов)

Процесс:

public async Task Handle(ActivityCreated @event, CancellationToken cancellationToken)
{
// 1. Проверяем, используется ли новая схема расчетов
var isNewScheme = await _marketingProgramService.IsNewSchemeByMarketingProgram(
@event.MarketingProgramId, cancellationToken);
if (!isNewScheme)
return; // Старая схема - не обрабатываем

var activityId = ActivityId.With(Guid.Parse(@event.AggregateId));

// 2. Рассчитываем вознаграждение
await _commandExecutor.Execute(
new CalculateRewardCommand(activityId), cancellationToken);

// 3. Применяем активность к подходящему заданию
var needSkipActivity = false;
var applyResult = await _commandExecutor.Execute(
new ApplyActivityCommand(activityId, null), cancellationToken);

applyResult.Switch(
_ => { },
notFound => throw new Exception(notFound.Message),
_ => needSkipActivity = true, // Не найдено подходящее задание
invalidOperation => throw new Exception(invalidOperation.Message),
conflict => throw new Exception(conflict.Message));

// 4. Если не найдено подходящее задание - пропускаем
if (needSkipActivity)
await _commandExecutor.Execute(
new SkipActivityCommand(activityId), cancellationToken);
}

Важно: Этот обработчик запускает автоматический pipeline обработки активности для новой схемы расчетов.

ActivityRewardSet

Обработчик: ActivityRewardSet.Handler в Subscriptions/Activities/

Файл: src/Scount.Application/Subscriptions/Activities/ActivityRewardSet/ActivityRewardSet.Handler.cs

Назначение: Создание операции пополнения баланса при установке вознаграждения

Процесс:

public async Task Handle(ActivityRewardSet @event, CancellationToken cancellationToken)
{
var activityId = Guid.Parse(@event.AggregateId);

// 1. Получаем ParticipantId из read model
var activity = await _readModelQueryExecutor.FirstOrDefaultAsync(
_readModel.Activities.Where(a => a.ActivityId == activityId)
.Select(a => new { a.ParticipantId }),
cancellationToken);

if (activity == null)
throw new Exception($"Activity '{activityId}' not found.");

// 2. Создаем операцию пополнения баланса
await _commandExecutor.Execute(
new RefillBalanceOperationCommand(
BalanceId.With(activity.ParticipantId),
BalanceOperationId.With(activityId),
BalanceOperationSourceId.With(activityId),
BalanceOperationSource.Activity,
BalanceOperationType.Refill,
BalanceOperationState.Accepted,
Amount.With(@event.Reward)),
cancellationToken);
}

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

ActivityApplied

Обработчик: ActivityApplied.Handler в Subscriptions/Activities/

Файл: src/Scount.Application/Subscriptions/Activities/ActivityApplied/ActivityApplied.Handler.cs

Назначение: Автоматическое завершение задания участника после применения активности (для новой схемы)

Процесс:

public async Task Handle(ActivityApplied @event, CancellationToken cancellationToken)
{
var activityId = Guid.Parse(@event.AggregateId);

// 1. Проверяем, используется ли новая схема
var isNewScheme = await _marketingProgramService.IsNewSchemeByActivity(
activityId, cancellationToken);
if (!isNewScheme)
return; // Для старой схемы обработка в Task Manager

// 2. Получаем ParticipantExerciseId из задания
var participantExerciseId = await _readModelQueryExecutor.FirstOrDefaultAsync(
_readModel.ParticipantExerciseTasks
.Where(pet => pet.ParticipantExerciseTaskId == @event.ParticipantExerciseTaskId)
.Select(pet => pet.ParticipantExerciseId), cancellationToken);

// 3. Завершаем задание участника
await _commandExecutor.Execute(
new CompleteParticipantExerciseTaskCommand(
ParticipantExerciseId.With(participantExerciseId),
ParticipantExerciseTaskId.With(@event.ParticipantExerciseTaskId),
null,
isNewScheme,
@event.OccurredOn), cancellationToken);
}

Важные моменты:

  1. Новая схема (IsNewScheme = true):

    • При применении активности к заданию автоматически завершается задание участника
    • Выполняется команда CompleteParticipantExerciseTaskCommand
    • Это позволяет сразу отмечать задание как выполненное
  2. Старая схема (IsNewScheme = false):

    • Обработка игнорируется в этом handler
    • Завершение задания обрабатывается через внешние Task Managers

Связанные события: ActivityApplied генерируется методом Activity.Apply(ParticipantExerciseTaskId) в доменной модели.

Публикация событий для внешних систем

Обработчики в Subscriptions/__ForTimeline/, Subscriptions/__External/

Доменные события Activity публикуются:

  • В Timeline (для отображения в ленте событий)
  • Во внешние Task Managers (GeoActivitiesTaskManager, QuizesTaskManager, InvitesTaskManager и др.)

ActivityApplied для Timeline

Обработчик: ActivityApplied.Handler в Subscriptions/__ForTimeline/

Файл: src/Scount.Application/Subscriptions/__ForTimeline/ActivityApplied/ActivityApplied.Handler.cs

Назначение: Публикация информации о применении активности в Timeline

Процесс:

public Task Handle(ActivityApplied @event, CancellationToken cancellationToken)
{
var activityId = Guid.Parse(@event.AggregateId);

// Ставим в очередь job для отправки в Timeline
_backgroundJobScheduler.Enqueue<TimelineJob>(job =>
job.AddActivityAppliedAsync(
activityId,
@event.ParticipantExerciseTaskId,
cancellationToken));

return Task.CompletedTask;
}

Цель: Обновить ленту событий пользователя, чтобы отобразить применение активности к заданию.

ActivityApplied для Task Managers (старая схема)

Обработчик: ActivityApplied.Handler в Subscriptions/__External/QuizesTaskManager/ (и аналогичные для других Task Managers)

Файл: src/Scount.Application/Subscriptions/__External/QuizesTaskManager/ActivityApplied/ActivityApplied.Handler.cs

Назначение: Применение активности для программ со старой схемой расчетов

Процесс:

public async Task Handle(ActivityApplied @event, CancellationToken cancellationToken)
{
var activityId = Guid.Parse(@event.AggregateId);

// Проверяем, используется ли новая схема
var isNewScheme = await _marketingProgramService.IsNewSchemeByActivity(
activityId, cancellationToken);

if (isNewScheme)
return; // Для новой схемы обработка внутри Scount

// Для старой схемы выполняем ApplyActivityCommand
await _commandExecutor.Execute(
new ApplyActivityCommand(
ActivityId.With(activityId),
ParticipantExerciseTaskId.With(@event.ExerciseTaskId)),
cancellationToken);
}

Важно: Эти обработчики работают только для старой схемы, где применение активности управляется внешними Task Managers через Kafka события.


Расчет вознаграждения

ActivitiesService

Файл: src/Scount.Infrastructure/Services/Activities/ActivitiesService.cs

Интерфейс: IActivitiesService

Методы:

public interface IActivitiesService
{
// Валидация данных активности
void ValidateActivityData(ActivityType activityType, IDictionary<string, string> data);

// Расчет вознаграждения
Task<decimal?> CalculateRewardAsync(
MarketingProgramId marketingProgramId,
ActivityType activityType,
IDictionary<string, string> data,
CancellationToken cancellationToken);

// Поиск подходящего задания
Task<ParticipantExerciseTaskId?> GetSuitableParticipantExerciseTaskIdAsync(
Activity activity,
CancellationToken cancellationToken);
}

Процесс расчета вознаграждения

Файл: src/Scount.Infrastructure/Services/Activities/ActivitiesService.cs

public async Task<decimal?> CalculateRewardAsync(
MarketingProgramId marketingProgramId,
ActivityType activityType,
IDictionary<string, string> data,
CancellationToken cancellationToken)
{
// 1. Получаем специфичный сервис для типа активности
var service = Resolve(activityType); // SportActivitiesService, SporadicActivitiesService и т.д.

// 2. Проверяем, может ли активность быть рассчитана
if (!service.CanActivityBeCalculated(data))
return null; // Например, для отклоненных тренировок

// 3. Получаем формулы для данного типа активности и маркетинговой программы
var formulas = await GetFormulasAsync(activityType, marketingProgramId, cancellationToken);

// 4. Конвертируем данные активности в переменные для формулы
var activityVariables = service.GetFormulaVariables(data);

// 5. Выбираем подходящую формулу (по условию __where)
var formula = formulas.FirstOrDefault(f =>
ActivityRewardCalculator.IsSuitable(f.Settings, activityVariables));

if (formula == null)
return null; // Нет подходящей формулы

// 6. Рассчитываем вознаграждение
return ActivityRewardCalculator.CalculateReward(
activityVariables,
formula.Expression,
formula.Coefficients);
}

ActivityRewardCalculator

Файл: src/Scount.Infrastructure/Services/Activities/ActivityRewardCalculator.cs

Использует библиотеку NCalc для вычисления математических выражений.

Основные функции:

public static decimal CalculateReward(
Dictionary<string, object?> variables, // Переменные активности
string expression, // Формула вознаграждения
IDictionary<string, string>? coefficients) // Коэффициенты

Поддерживаемые функции в формулах:

  • coeff(path) - получение коэффициента из JSON
  • piecewise(table, x) - кусочная функция (таблица множителей)
  • after_linear(x, threshold, rate, cap) - линейная функция после порога с ограничением
  • clamp(x, min, max) - ограничение значения
  • min(a, b), max(a, b) - минимум/максимум
  • coalesce(a, b, ...) - первое не-null значение
  • in(value, ...) - проверка вхождения в список

Пример формулы:

distance * coeff('base_rate') * piecewise('distance_table', distance) * clamp(intensity, 0.5, 1.5)

Специфичные сервисы по типам активностей

Расположение: src/Scount.Infrastructure/Services/Activities/Instances/

Примеры:

  • SportActivitiesService - для спортивных тренировок
  • SporadicActivitiesService - для нециклических активностей
  • QuizesActivitiesService - для квизов
  • InvitesActivitiesService - для приглашений
  • И т.д.

Пример SportActivitiesService:

Файл: src/Scount.Infrastructure/Services/Activities/Instances/SportActivitiesService.cs

public sealed class SportActivitiesService : AbstractActivitiesService
{
// Валидация данных
public override void ValidateActivityData(IDictionary<string, string> data)
=> SportActivity.Validate(data);

// Может ли быть рассчитано вознаграждение
public override bool CanActivityBeCalculated(IDictionary<string, string> data)
{
var sportActivity = new SportActivity(data);
return sportActivity.RefereeingSystemCheckState.Equals("Accepted");
}

// Конвертация в переменные формулы
public override Dictionary<string, object?> GetFormulaVariables(IDictionary<string, string> data)
{
var activity = new SportActivity(data);

return new Dictionary<string, object?>
{
["tracker_id"] = activity.TrackerId.ToString(),
["provider"] = activity.Provider,
["discipline"] = activity.Discipline,
["distance"] = activity.Distance,
["time_in_seconds"] = activity.TimeInSeconds,
["avg_pace"] = activity.AveragePace / 60,
["avg_speed"] = activity.AverageSpeed,
["height_diff"] = activity.HeightDifference,
["pulse"] = activity.Pulse,
["is_manual"] = activity.IsManual
};
}

// Поиск подходящего задания
public override async Task<ParticipantExerciseTaskId?> GetSuitableParticipantExerciseTaskIdAsync(
Activity activity, CancellationToken cancellationToken)
{
var sportActivity = new SportActivity(activity.Data);

// Отклоненные активности не применяются
if (sportActivity.RefereeingSystemCheckState.Equals("Rejected"))
return null;

// Делегируем поиск ParticipantExerciseTasksService
return await _participantExerciseTasksService.GetSuitableParticipantExerciseTaskAsync(
activity.ParticipantId,
activity.Data,
IsSuitableForExerciseTask,
cancellationToken);
}

// Проверка соответствия активности заданию
private static bool IsSuitableForExerciseTask(
IDictionary<string, string> data,
IDictionary<string, string> exerciseTaskSettings)
{
var activity = new SportActivity(data);
var settings = GetSportActivitySettings(exerciseTaskSettings);

// Проверяем дисциплину, дистанцию, время, темп
return settings.Discipline == activity.Discipline &&
(settings.MinDistance == null || activity.Distance >= settings.MinDistance) &&
(settings.MaxDistance == null || activity.Distance <= settings.MaxDistance) &&
(settings.MinTimeInSeconds == null || activity.TimeInSeconds >= settings.MinTimeInSeconds) &&
(settings.MaxTimeInSeconds == null || activity.TimeInSeconds <= settings.MaxTimeInSeconds) &&
(settings.MinPace == null || activity.AveragePace <= settings.MinPace) &&
(settings.MaxPace == null || activity.AveragePace >= settings.MaxPace);
}
}

Интеграции с внешними сервисами

SportData / SportActivities

Назначение: Получение спортивных тренировок от трекеров (Strava, Garmin и т.д.)

Механизмы интеграции:

  1. Callback API (синхронный)

    • Endpoint: POST /api/v{version}/sport-activities-callback/activity-accepted
    • Controller: SportActivitiesCallbackController
  2. Kafka Events (асинхронный)

    • Topic: sport-activities.activity-created
    • Handler: Subscriptions/__External/SportActivities/ActivityCreated

Данные:

  • TrackerId, Provider, Discipline
  • StartDate, FinishDate
  • Distance, TimeInSeconds
  • AveragePace, AverageSpeed
  • HeightDifference, Pulse
  • RefereeingSystemCheckState (Accepted/Rejected)

SporadicActivitiesTaskManager

Назначение: Получение нециклических активностей (разовые тренировки)

Механизмы интеграции:

  1. Callback API

    • Endpoint: POST /api/v{version}/sporadic-activities-callback/activity-accepted
    • Controller: SporadicActivitiesCallbackController
  2. Kafka Events

    • Handler: Subscriptions/__External/SporadicActivitiesTaskManager/

События от Scount к Task Managers:

  • ActivityCreated - отправляется в GeoActivitiesTaskManager, InvitesTaskManager
  • ActivityApplied - отправляется в GeoActivitiesTaskManager, QuizesTaskManager

Другие Task Managers

  • QuizesTaskManager - получает события о создании и применении активностей квизов
  • InvitesTaskManager - получает события о создании активностей приглашений
  • PartnersTaskManager - получает события о создании активностей партнеров
  • UploadImageActivitiesTaskManager - получает события о загрузке изображений

Queries для получения активностей

GetActivityByIdQuery

Файл: src/Scount.Application/Queries/Activities/GetActivityByIdQuery/

Назначение: Получение активности по ID

Параметры:

  • ActivityId - ID активности

Результат:

  • SuccessResult с данными активности
  • NotFoundResult

Read Model

Файл: src/Scount.Application/ReadModels/Models/ActivityModel.cs

Интерфейс: IReadModel.Activities

Queries используют IReadModel для чтения данных (не репозитории!).


Схема полного процесса

1. Получение активности от внешней системы

┌──────────────────────┐
│ SportData Service │
│ (External System) │
└──────────┬───────────┘

│ HTTP POST Callback
│ or Kafka Event
v
┌──────────────────────────────┐
│ SportActivitiesCallback │
│ Controller / Event Handler │
└──────────┬───────────────────┘

│ Execute Command
v
┌──────────────────────────────┐
│ CreateActivityCommand │
└──────────┬───────────────────┘

v

2. Создание и валидация активности

┌──────────────────────────────┐
│ CreateActivityCommand.Handler│
└──────────┬───────────────────┘

├─► Check if exists (idempotency)

├─► ValidateActivityData
│ (via IActivitiesService)

├─► Get Participant
│ (check not blocked)

├─► participant.CreateActivity()
│ (creates Activity aggregate)

└─► Save to repository

v
┌───────────────────┐
│ ActivityCreated │ Domain Event
│ published │
└───────┬───────────┘

v

3. Автоматическая обработка (новая схема)

┌─────────────────────────┐
│ ActivityCreated.Handler │
└───────┬─────────────────┘

├─► Check isNewScheme

├─► CalculateRewardCommand
│ │
│ v
│ ┌─────────────────┐
│ │ ActivitiesServ. │
│ │ .CalculateReward│
│ └────┬────────────┘
│ │
│ ├─► CanBeCalculated?
│ ├─► GetFormulas
│ ├─► GetFormulaVariables
│ ├─► Select formula
│ └─► RewardCalculator
│ │
│ v
│ ┌──────────────┐
│ │ SetReward() │
│ └───┬──────────┘
│ │
│ v
│ ┌──────────────────┐
│ │ ActivityRewardSet│ Event
│ └───┬──────────────┘
│ │
│ v
│ ┌───────────────────────┐
│ │ ActivityRewardSet │
│ │ .Handler │
│ │ (Balance refill) │
│ └───────────────────────┘

└─► ApplyActivityCommand

v
┌──────────────────┐
│ ActivitiesService│
│ .GetSuitable │
│ ExerciseTaskId │
└────┬─────────────┘

├─► Found?
│ │
│ ├─Yes─► activity.Apply(taskId)
│ │ │
│ │ v
│ │ ┌───────────────┐
│ │ │ActivityApplied│ Event
│ │ └───┬───────────┘
│ │ │
│ │ v
│ │ ┌─────────────────────┐
│ │ │ ActivityApplied │
│ │ │ .Handler │
│ │ └───┬─────────────────┘
│ │ │
│ │ ├─► Complete task (новая схема)
│ │ ├─► Publish to Timeline
│ │ └─► (старая схема через TaskMgr)
│ │
│ └─No──► SkipActivityCommand
│ │
│ v
│ ┌───────────────┐
│ │ActivitySkipped│ Event
│ └───────────────┘
v

4. Диаграмма состояний процесса

┌─────────────────┐
│ External System │
│ (SportData) │
└────────┬────────┘

│ POST callback / Kafka event
v
┌──────────────────────────────────────┐
│ CreateActivityCommand │
│ │
│ - Validate data │
│ - Check participant not blocked │
│ - Create Activity (state: Created) │
└────────┬─────────────────────────────┘

│ ActivityCreated event
v
┌──────────────────────────────────────┐
│ ActivityCreated.Handler │
│ (only for new scheme) │
└────┬────────────────────────┬────────┘
│ │
│ Calculate │ Apply
v v
┌──────────────┐ ┌──────────────────────┐
│ Calculate │ │ Find suitable │
│ Reward │ │ Exercise Task │
│ │ │ │
│ SetReward() │ │ Found? │
└──────┬───────┘ └───┬────────────┬─────┘
│ │ │
│ │Yes │No
v v v
┌──────────────┐ ┌──────────┐ ┌──────────┐
│ActivityReward│ │ Apply() │ │ Skip() │
│Set event │ │ state: │ │ state: │
│ │ │ Applied │ │ Skipped │
└──────┬───────┘ └──────┬───┘ └────────┬─┘
│ │ │
v v v
┌────────────────┐ ┌─────────────┐ ┌─────────────┐
│Create Balance │ │ActivityApplied│ ActivitySkipped│
│Refill Operation│ │event │ │event │
└────────────────┘ └─────────────┘ └─────────────┘

Репозиторий Activities

Интерфейс: IActivitiesRepository

Файл: src/Scount.Application/Repositories/IActivitiesRepository.cs

public interface IActivitiesRepository : IAggregateRootRepository<Guid, ActivityId, Activity>
{
ValueTask<bool> AnyAnotherAsync(
ParticipantExerciseTaskId participantExerciseTaskId,
ActivityId activityId,
CancellationToken cancellationToken = default);
}

Методы:

  • FindById(ActivityId) - поиск по ID (от IAggregateRootRepository)
  • Save(Activity) - сохранение (от IAggregateRootRepository)
  • AnyAnotherAsync(taskId, activityId) - проверка, применена ли другая активность к заданию

Связь с Balances (вознаграждение)

Когда устанавливается вознаграждение (ActivityRewardSet event), обработчик создает операцию пополнения баланса:

RefillBalanceOperationCommand(
BalanceId: participantId, // Баланс участника
BalanceOperationId: activityId, // ID операции = ID активности
BalanceOperationSourceId: activityId, // Источник = ID активности
BalanceOperationSource: Activity, // Тип источника
BalanceOperationType: Refill, // Пополнение
BalanceOperationState: Accepted, // Сразу принято
Amount: reward // Сумма вознаграждения
)

Важно:

  • Одна активность = одна операция баланса
  • ID операции совпадает с ID активности (идемпотентность)
  • Операция создается в состоянии "Accepted" (сразу применяется)

Jobs для фоновой обработки

Расположение: src/Scount.Application/Jobs/

На данный момент нет специфичных jobs для обработки активностей. Обработка происходит автоматически через события.

Возможные будущие jobs:

  • Пересчет вознаграждений для активностей
  • Повторная попытка применения пропущенных активностей
  • Очистка старых активностей

Валидация данных активностей

Каждый тип активности имеет свою логику валидации в соответствующем сервисе:

Пример для SportActivity:

public static void Validate(IDictionary<string, string> data)
{
// Проверка наличия и формата обязательных полей
if (!Guid.TryParse(data["TrackerId"], out _))
throw new ActivityDataValidationException("Invalid TrackerId.");

if (!DateTime.TryParse(data["StartDate"], CultureInfo.InvariantCulture, out var start))
throw new ActivityDataValidationException("Invalid StartDate.");

if (!DateTime.TryParse(data["FinishDate"], CultureInfo.InvariantCulture, out var finish))
throw new ActivityDataValidationException("Invalid FinishDate.");

// Бизнес-правила
if (finish < start)
throw new ActivityDataValidationException(
"FinishDate must be greater than or equal to StartDate.");

if (distance <= 0)
throw new ActivityDataValidationException(
"Distance must be greater than 0.");

// И т.д.
}

Ключевые моменты архитектуры

  1. Event Sourcing не используется - агрегаты хранят текущее состояние, события используются только для интеграции

  2. Идемпотентность - все команды проверяют существование объектов и возвращают Success при повторном вызове с теми же данными

  3. CQRS - строгое разделение команд (используют репозитории) и queries (используют IReadModel)

  4. Domain Events - используются для связи агрегатов и публикации во внешние системы через Kafka

  5. Автоматический pipeline - для новой схемы расчетов активность автоматически обрабатывается: создание → расчет → применение/пропуск → вознаграждение

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

  7. Специализированные сервисы - каждый тип активности обрабатывается своим сервисом с конкретной логикой

  8. Формулы вознаграждений - гибкая система расчета через NCalc с поддержкой сложных математических операций


Примеры использования

Создание спортивной активности через API

POST /api/v1/sport-activities-callback/activity-accepted
Content-Type: application/json

{
"body": {
"activityId": "123e4567-e89b-12d3-a456-426614174000",
"userId": "user-guid",
"trackerId": "tracker-guid",
"provider": "Strava",
"discipline": "running",
"startDate": "2026-01-28T10:00:00Z",
"finishDate": "2026-01-28T11:00:00Z",
"distance": 5000,
"timeInSeconds": 1800,
"averagePace": 6.0,
"averageSpeed": 10.0,
"isManual": false,
"pulse": 150,
"heightDifference": 50.5
}
}

Результат:

  1. Создается Activity (state: Created)
  2. Публикуется ActivityCreated event
  3. Рассчитывается вознаграждение
  4. Ищется подходящее задание
  5. Активность применяется или пропускается
  6. Создается операция пополнения баланса

Получение активности по ID

GET /api/v1/activities/123e4567-e89b-12d3-a456-426614174000

Ответ:

{
"activityId": "123e4567-e89b-12d3-a456-426614174000",
"participantId": "participant-guid",
"marketingProgramId": "program-guid",
"participantExerciseTaskId": "task-guid",
"type": "SportActivity",
"state": "Applied",
"reward": 100.0,
"data": {
"TrackerId": "tracker-guid",
"Provider": "Strava",
"Discipline": "running",
...
},
"createdOn": "2026-01-28T10:00:00Z"
}

Заключение

Система обработки активностей в Scount представляет собой сложный автоматизированный процесс, который:

  1. Получает активности из различных источников (callbacks, Kafka)
  2. Валидирует данные активности
  3. Создает доменные объекты через агрегаты
  4. Автоматически рассчитывает вознаграждение по формулам
  5. Находит подходящие задания участника
  6. Применяет активность или пропускает её
  7. Создает операции баланса для выплаты вознаграждений
  8. Публикует события для внешних систем и Timeline

Вся логика следует принципам DDD, CQRS и event-driven архитектуры, обеспечивая гибкость, расширяемость и надежность системы.