VEU.WathBody/3-app/2-cli/Server.WatchBody.Web/Services/SyncService.cs

301 lines
14 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 = "Программа" });
}