Процесс получения и обработки активностей в Scount
Версия: 1.0 Дата: 2026-01-28 Сервис: Scount
Оглавление
- Обзор системы
- Доменная модель Activity
- Типы активностей
- Состояния активности
- Процесс получения активностей
- Команды для работы с активностями
- Обработчики доменных событий
- Расчет вознаграждения
- Интеграции с внешними сервисами
- Queries для получения активностей
- Схема полного процесса
Обзор системы
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/
- ActivityCreated - активность создана
- ActivityDataUpdated - данные активности обновлены
- ActivityApplied - активность применена к заданию
- ActivitySkipped - активность пропущена
- 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.cssrc/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- тип активности
Результаты:
SuccessResultNotFoundResultInvalidOperationResultConflictResult
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);
}
Важные моменты:
-
Новая схема (IsNewScheme = true):
- При применении активности к заданию автоматически завершается задание участника
- Выполняется команда
CompleteParticipantExerciseTaskCommand - Это позволяет сразу отмечать задание как выполненное
-
Старая схема (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)- получение коэффициента из JSONpiecewise(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 и т.д.)
Механизмы интеграции:
-
Callback API (синхронный)
- Endpoint:
POST /api/v{version}/sport-activities-callback/activity-accepted - Controller:
SportActivitiesCallbackController
- Endpoint:
-
Kafka Events (асинхронный)
- Topic:
sport-activities.activity-created - Handler:
Subscriptions/__External/SportActivities/ActivityCreated
- Topic:
Данные:
- TrackerId, Provider, Discipline
- StartDate, FinishDate
- Distance, TimeInSeconds
- AveragePace, AverageSpeed
- HeightDifference, Pulse
- RefereeingSystemCheckState (Accepted/Rejected)
SporadicActivitiesTaskManager
Назначение: Получение нециклических активностей (разовые тренировки)
Механизмы интеграции:
-
Callback API
- Endpoint:
POST /api/v{version}/sporadic-activities-callback/activity-accepted - Controller:
SporadicActivitiesCallbackController
- Endpoint:
-
Kafka Events
- Handler:
Subscriptions/__External/SporadicActivitiesTaskManager/
- Handler:
События от Scount к Task Managers:
ActivityCreated- отправляется в GeoActivitiesTaskManager, InvitesTaskManagerActivityApplied- отправляется в 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.");
// И т.д.
}
Ключевые моменты архитектуры
-
Event Sourcing не используется - агрегаты хранят текущее состояние, события используются только для интеграции
-
Идемпотентность - все команды проверяют существование объектов и возвращают Success при повторном вызове с теми же данными
-
CQRS - строгое разделение команд (используют репозитории) и queries (используют IReadModel)
-
Domain Events - используются для связи агрегатов и публикации во внешние системы через Kafka
-
Автоматический pipeline - для новой схемы расчетов активность автоматически обрабатывается: создание → расчет → применение/пропуск → вознаграждение
-
Типизированные результаты - использование OneOf для явного указания возможных результатов команд
-
Специализированные сервисы - каждый тип активности обрабатывается своим сервисом с конкретной логикой
-
Формулы вознаграждений - гибкая система расчета через 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
}
}
Результат:
- Создается Activity (state: Created)
- Публикуется ActivityCreated event
- Рассчитывается вознаграждение
- Ищется подходящее задание
- Активность применяется или пропускается
- Создается операция пополнения баланса
Получение активности по 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 представляет собой сложный автоматизированный процесс, который:
- Получает активности из различных источников (callbacks, Kafka)
- Валидирует данные активности
- Создает доменные объекты через агрегаты
- Автоматически рассчитывает вознаграждение по формулам
- Находит подходящие задания участника
- Применяет активность или пропускает её
- Создает операции баланса для выплаты вознаграждений
- Публикует события для внешних систем и Timeline
Вся логика следует принципам DDD, CQRS и event-driven архитектуры, обеспечивая гибкость, расширяемость и надежность системы.