301 lines
14 KiB
C#
301 lines
14 KiB
C#
using System.IO;
|
||
using System.Text.Json;
|
||
using System.Transactions;
|
||
using Server.WatchBody.Core.Dal.Ref.Repository;
|
||
using Server.WatchBody.Core.Dal.Src.Domain;
|
||
using Server.WatchBody.Core.Dal.Src.Repository;
|
||
using Server.WatchBody.Web.Dto;
|
||
using CoreProgram = Server.WatchBody.Core.Dal.Src.Domain.Program;
|
||
|
||
namespace Server.WatchBody.Web.Services;
|
||
|
||
/// <summary>
|
||
/// POST /api/v1/sync (spec 03-sync-post). Пакет атомарен («все или
|
||
/// ничего»): записи применяются внутри TransactionScope — Kit.Helpers
|
||
/// открывает соединение на каждый вызов, но Npgsql подключает их к
|
||
/// ambient-транзакции (Enlist по умолчанию). Все ЧТЕНИЯ выполняются до
|
||
/// открытия scope (select-функции Kit открывают собственную транзакцию —
|
||
/// вложенная запрещена); внутри scope — только записи. Идемпотентность —
|
||
/// по client_guid (PK uuid); athlete_id всех записей — из middleware
|
||
/// (🛡 изоляция).
|
||
/// </summary>
|
||
public class SyncService(
|
||
IWorkoutRepository workouts,
|
||
IWorkoutSetRepository sets,
|
||
IExerciseRepository exercises,
|
||
IExerciseGroupRepository groups,
|
||
IProgramRepository programs,
|
||
IProgramVersionRepository versions,
|
||
IAthleteProfileRepository profiles,
|
||
IDailyLogRepository dailyLogs,
|
||
IBreakRepository breaks,
|
||
Core.Metrics.MetricsEngine metricsEngine,
|
||
Core.Programs.ProgressionRules progressionRules,
|
||
Core.Programs.BreakEngine breakEngine,
|
||
Microsoft.Extensions.Logging.ILogger<SyncService> logger)
|
||
{
|
||
private enum CoreWorkoutState { New, Exists }
|
||
|
||
public List<Guid> Process(int athleteId, IReadOnlyList<SyncItem> items)
|
||
{
|
||
// пре-проход: чтения и проверки (до транзакции)
|
||
var groupIds = groups.Select().ToDictionary(g => g.Code, g => g.Id);
|
||
var workoutStates = new Dictionary<Guid, CoreWorkoutState>();
|
||
var existingWorkouts = new Dictionary<Guid, Workout>();
|
||
var program = programs.Select(athleteId).FirstOrDefault();
|
||
var latestVersion = program == null ? 0
|
||
: versions.Select(new ProgramVersionFilter { AthleteId = athleteId, ProgramId = program.Id })
|
||
.Select(v => v.Version).DefaultIfEmpty(0).Max();
|
||
|
||
foreach (var item in items)
|
||
{
|
||
switch (item.Entity)
|
||
{
|
||
case "workout":
|
||
var existing = workouts.Get(athleteId, item.ClientGuid);
|
||
workoutStates[item.ClientGuid] = existing == null
|
||
? CoreWorkoutState.New
|
||
: CoreWorkoutState.Exists;
|
||
if (existing != null) existingWorkouts[item.ClientGuid] = existing;
|
||
break;
|
||
case "exercise":
|
||
if (!groupIds.ContainsKey(item.Data.GetProperty("group").GetString()!))
|
||
throw new SyncException(
|
||
$"неизвестная группа '{item.Data.GetProperty("group").GetString()}'");
|
||
break;
|
||
case "program_version":
|
||
var basedOn = SyncItemParser.IntOrNull(item.Data, "based_on_version");
|
||
if (basedOn != null && basedOn != latestVersion)
|
||
throw new SyncException("program_version_conflict", 409, "program_version_conflict");
|
||
break;
|
||
}
|
||
}
|
||
|
||
// транзакция: только записи (scalar-функции); в отдельном методе —
|
||
// чтобы scope (и ambient-транзакция) завершились до пересчёта метрик
|
||
ApplyAll(athleteId, items, groupIds, existingWorkouts, program?.Id);
|
||
|
||
// метрики — производные данные: пересчёт после фиксации пакета;
|
||
// сбой расчёта логируется, sync остаётся принятым (m04, 🛡4)
|
||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||
foreach (var workoutId in items
|
||
.Where(i => i.Entity == "workout")
|
||
.Select(i => i.ClientGuid).Distinct())
|
||
{
|
||
try
|
||
{
|
||
metricsEngine.ComputeForWorkout(athleteId, workoutId);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
logger.LogWarning(ex,
|
||
"metrics: расчёт тренировки {WorkoutId} не удался", workoutId);
|
||
}
|
||
|
||
// правила прогрессии/перерывов (m05): тоже после коммита,
|
||
// сбой не влияет на ответ sync (module_reqs 🛡5)
|
||
try
|
||
{
|
||
breakEngine.CloseOnReturn(athleteId, workoutId, today);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
logger.LogWarning(ex,
|
||
"rules: возврат после break не удался ({WorkoutId})", workoutId);
|
||
}
|
||
try
|
||
{
|
||
progressionRules.EvaluateAfterWorkout(athleteId, workoutId);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
logger.LogWarning(ex,
|
||
"rules: микроправка не удалась ({WorkoutId})", workoutId);
|
||
}
|
||
}
|
||
|
||
try
|
||
{
|
||
breakEngine.DetectBreak(athleteId, today);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
logger.LogWarning(ex, "rules: детект break не удался");
|
||
}
|
||
try
|
||
{
|
||
progressionRules.CheckDeload(athleteId, today);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
logger.LogWarning(ex, "rules: делоад не удался");
|
||
}
|
||
|
||
return items.Select(i => i.ClientGuid).ToList();
|
||
}
|
||
|
||
private void ApplyAll(
|
||
int athleteId, IReadOnlyList<SyncItem> items, Dictionary<string, int> groupIds,
|
||
Dictionary<Guid, Workout> existingWorkouts, int? programId)
|
||
{
|
||
using var scope = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled);
|
||
foreach (var item in items)
|
||
{
|
||
Apply(athleteId, item, groupIds, existingWorkouts, programId);
|
||
}
|
||
scope.Complete();
|
||
}
|
||
|
||
private void Apply(
|
||
int athleteId, SyncItem item, Dictionary<string, int> groupIds,
|
||
Dictionary<Guid, Workout> existingWorkouts, int? programId)
|
||
{
|
||
var d = item.Data;
|
||
switch (item.Entity)
|
||
{
|
||
case "workout":
|
||
ApplyWorkout(athleteId, item.ClientGuid, d, existingWorkouts.GetValueOrDefault(item.ClientGuid));
|
||
break;
|
||
case "workout_set":
|
||
sets.Insert(new WorkoutSet
|
||
{
|
||
Id = item.ClientGuid,
|
||
WorkoutId = d.GetProperty("workout_id").GetGuid(),
|
||
AthleteId = athleteId,
|
||
ExerciseId = SyncItemParser.StringOrNull(d, "exercise_id") is { } exId ? Guid.Parse(exId) : null,
|
||
Seq = d.GetProperty("seq").GetInt32(),
|
||
WeightKg = d.GetProperty("weight_kg").GetDecimal(),
|
||
Reps = d.GetProperty("reps").GetInt32(),
|
||
DoneAt = SyncItemParser.UtcDateOrNull(d, "done_at")!.Value,
|
||
Source = d.GetProperty("source").GetString()!,
|
||
InPlan = SyncItemParser.BoolOrNull(d, "in_plan") ?? false,
|
||
Rpe = SyncItemParser.IntOrNull(d, "rpe"),
|
||
KneePain = SyncItemParser.IntOrNull(d, "knee_pain"),
|
||
});
|
||
break;
|
||
case "exercise":
|
||
// З-41(а): is_active=false — логическое удаление (Ф-4.2,
|
||
// src.exercise_delete; идемпотентно: 0 строк — не ошибка);
|
||
// единственный серверный путь установки флага в v1 — им
|
||
// пользуется фильтр ограничений m05
|
||
if (SyncItemParser.BoolOrNull(d, "is_active") == false)
|
||
{
|
||
exercises.Delete(athleteId, item.ClientGuid);
|
||
}
|
||
else
|
||
{
|
||
exercises.Insert(new Exercise
|
||
{
|
||
Id = item.ClientGuid,
|
||
AthleteId = athleteId,
|
||
Name = d.GetProperty("name").GetString()!,
|
||
GroupId = groupIds[d.GetProperty("group").GetString()!],
|
||
IsBase = SyncItemParser.BoolOrNull(d, "is_base") ?? true,
|
||
IsActive = true,
|
||
ReplacementIds = [],
|
||
});
|
||
}
|
||
break;
|
||
case "program_version":
|
||
versions.Insert(athleteId, new ProgramVersion
|
||
{
|
||
Id = item.ClientGuid,
|
||
ProgramId = programId ?? CreateProgram(athleteId),
|
||
Version = 0, // номер считает БД (max+1)
|
||
BasedOnVersion = SyncItemParser.IntOrNull(d, "based_on_version"),
|
||
Status = "draft", // выставит БД
|
||
Author = SyncItemParser.StringOrNull(d, "author") ?? "user",
|
||
Body = d.GetRawText(),
|
||
Rationale = SyncItemParser.StringOrNull(d, "rationale") ?? string.Empty,
|
||
});
|
||
break;
|
||
case "profile":
|
||
profiles.Upsert(new AthleteProfile
|
||
{
|
||
AthleteId = athleteId,
|
||
Sex = SyncItemParser.StringOrNull(d, "sex"),
|
||
BirthYear = SyncItemParser.IntOrNull(d, "birth_year"),
|
||
WeightKg = SyncItemParser.DecimalOrNull(d, "weight_kg"),
|
||
HrMax = SyncItemParser.IntOrNull(d, "hr_max"),
|
||
});
|
||
break;
|
||
case "daily_log":
|
||
dailyLogs.Upsert(athleteId, new DailyLog
|
||
{
|
||
Id = item.ClientGuid,
|
||
AthleteId = athleteId,
|
||
Date = SyncItemParser.DayOrNull(d, "date")!.Value,
|
||
BpSys = SyncItemParser.IntOrNull(d, "bp_sys"),
|
||
BpDias = SyncItemParser.IntOrNull(d, "bp_dias"),
|
||
WeightKg = SyncItemParser.DecimalOrNull(d, "weight_kg"),
|
||
HrResting = SyncItemParser.IntOrNull(d, "hr_resting"),
|
||
Wellbeing = SyncItemParser.IntOrNull(d, "wellbeing"),
|
||
});
|
||
break;
|
||
case "break":
|
||
breaks.Insert(athleteId, new Break
|
||
{
|
||
Id = item.ClientGuid,
|
||
AthleteId = athleteId,
|
||
Kind = SyncItemParser.StringOrNull(d, "kind"),
|
||
Source = SyncItemParser.StringOrNull(d, "source") ?? "manual",
|
||
StartedOn = SyncItemParser.DayOrNull(d, "started_on")!.Value,
|
||
PlannedReturn = SyncItemParser.DayOrNull(d, "planned_return"),
|
||
Note = SyncItemParser.StringOrNull(d, "note") ?? string.Empty,
|
||
});
|
||
break;
|
||
default:
|
||
throw new SyncException($"неизвестная entity '{item.Entity}'");
|
||
}
|
||
}
|
||
|
||
private void ApplyWorkout(int athleteId, Guid workoutId, JsonElement d, Workout? existingRow)
|
||
{
|
||
workouts.Insert(athleteId, new Workout
|
||
{
|
||
Id = workoutId,
|
||
AthleteId = athleteId,
|
||
ProgramVersionId = SyncItemParser.StringOrNull(d, "program_version_id") is { } pv
|
||
? Guid.Parse(pv)
|
||
: null,
|
||
StartedAt = SyncItemParser.UtcDateOrNull(d, "started_at")!.Value,
|
||
Status = "active",
|
||
SessionKind = SyncItemParser.StringOrNull(d, "session_kind") ?? "normal",
|
||
Sets = [],
|
||
Commands = [],
|
||
});
|
||
|
||
var endedAt = SyncItemParser.UtcDateOrNull(d, "ended_at");
|
||
var status = SyncItemParser.StringOrNull(d, "status");
|
||
var avgHr = SyncItemParser.IntOrNull(d, "avg_hr");
|
||
if (endedAt == null && status == null && avgHr == null) return;
|
||
|
||
// дозакрытие: update по всем полям; факты .fit существуют только у
|
||
// уже существовавшей строки — их сохраняем (чтение сделано до scope)
|
||
var existing = existingRow;
|
||
Guid? programVersionId = SyncItemParser.StringOrNull(d, "program_version_id") is { } pvText
|
||
? Guid.Parse(pvText) : null;
|
||
var current = new Workout
|
||
{
|
||
Id = workoutId,
|
||
AthleteId = athleteId,
|
||
ProgramVersionId = programVersionId,
|
||
StartedAt = SyncItemParser.UtcDateOrNull(d, "started_at")!.Value,
|
||
Status = status ?? "active",
|
||
SessionKind = SyncItemParser.StringOrNull(d, "session_kind") ?? "normal",
|
||
EndedAt = endedAt,
|
||
AvgHr = avgHr,
|
||
FactsJson = existing?.FactsJson,
|
||
FitFileId = existing?.FitFileId,
|
||
HrRecoveryS = existing?.HrRecoveryS,
|
||
Sets = [],
|
||
Commands = [],
|
||
};
|
||
workouts.Update(athleteId, current);
|
||
}
|
||
|
||
private int CreateProgram(int athleteId) =>
|
||
// программа создаётся первой версией из sync
|
||
programs.Insert(new CoreProgram { AthleteId = athleteId, Title = "Программа" });
|
||
}
|