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; /// /// 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 /// (🛡 изоляция). /// 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 logger) { private enum CoreWorkoutState { New, Exists } public List Process(int athleteId, IReadOnlyList items) { // пре-проход: чтения и проверки (до транзакции) var groupIds = groups.Select().ToDictionary(g => g.Code, g => g.Id); var workoutStates = new Dictionary(); var existingWorkouts = new Dictionary(); 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 items, Dictionary groupIds, Dictionary 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 groupIds, Dictionary 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 = "Программа" }); }