using System.Globalization; using System.Text.Json; namespace Server.WatchBody.Web.Dto; /// Ошибка обработки пакета sync → HTTP-ответ с кодом и телом {"error": ...}. public class SyncException(string message, int statusCode = 400, string code = "bad_request") : Exception(message) { public int StatusCode { get; } = statusCode; public string Code { get; } = code; } /// Запись пакета sync (tech-requirements 2.1). public class SyncItem { public Guid ClientGuid { get; set; } public string Entity { get; set; } = string.Empty; public string Op { get; set; } = string.Empty; public JsonElement Data { get; set; } } public static class SyncItemParser { public static List Parse(string json) { JsonDocument doc; try { doc = JsonDocument.Parse(json); } catch (JsonException e) { throw new SyncException($"невалидный JSON: {e.Message}"); } using (doc) { if (doc.RootElement.ValueKind != JsonValueKind.Array) throw new SyncException("ожидается JSON-массив записей"); var items = new List(); foreach (var element in doc.RootElement.EnumerateArray()) items.Add(ParseItem(element)); if (items.Count > 50) throw new SyncException("пакет больше 50 записей"); return items; } } private static SyncItem ParseItem(JsonElement element) { var item = new SyncItem(); if (!element.TryGetProperty("client_guid", out var guidEl) || !Guid.TryParse(guidEl.GetString(), out var guid)) throw new SyncException("client_guid отсутствует или не uuid"); item.ClientGuid = guid; item.Entity = GetString(element, "entity"); item.Op = GetString(element, "op"); if (item.Op != "upsert") throw new SyncException($"op '{item.Op}' не поддерживается (только upsert)"); if (!element.TryGetProperty("data", out var data) || data.ValueKind != JsonValueKind.Object) throw new SyncException("data отсутствует или не объект"); item.Data = data.Clone(); ValidateEntity(item); return item; } private static string GetString(JsonElement element, string name, bool required = true) { if (!element.TryGetProperty(name, out var el) || el.ValueKind != JsonValueKind.String) { if (required) throw new SyncException($"поле '{name}' отсутствует или не строка"); return string.Empty; } return el.GetString() ?? string.Empty; } private static void ValidateEntity(SyncItem item) { var d = item.Data; switch (item.Entity) { case "workout": RequireDate(d, "started_at"); OneOf(d, "status", required: false, "active", "finished", "cancelled"); OneOf(d, "session_kind", required: false, "normal", "return", "deload", "test"); break; case "workout_set": RequireGuid(d, "workout_id"); RequireNum(d, "seq"); RequireNum(d, "weight_kg"); RequireNum(d, "reps"); RequireDate(d, "done_at"); OneOf(d, "source", required: true, "voice", "watch", "edit"); Range(d, "rpe", 1, 10); Range(d, "knee_pain", 0, 10); break; case "exercise": RequireString(d, "name"); OneOf(d, "group", required: true, "push", "pull", "legs", "core", "cardio"); break; case "program_version": RequireNum(d, "version"); // не используется (версию считает БД), но должен быть числом по схеме break; case "profile": OneOf(d, "sex", required: false, "m", "f"); break; case "daily_log": RequireDate(d, "date"); Range(d, "wellbeing", 1, 5); break; case "break": RequireDate(d, "started_on"); if (d.TryGetProperty("source", out var src) && src.ValueKind == JsonValueKind.String && src.GetString() == "auto") throw new SyncException("break.source='auto' из sync не принимается — auto проставляет сервер"); OneOf(d, "kind", required: false, "illness", "vacation", "workload", "injury", "other"); break; default: throw new SyncException($"неизвестная entity '{item.Entity}'"); } } private static void RequireString(JsonElement d, string name) { if (!d.TryGetProperty(name, out var el) || el.ValueKind != JsonValueKind.String || string.IsNullOrWhiteSpace(el.GetString())) throw new SyncException($"поле '{name}' обязательно"); } private static void RequireNum(JsonElement d, string name) { if (!d.TryGetProperty(name, out var el) || (el.ValueKind != JsonValueKind.Number && el.ValueKind != JsonValueKind.Null)) throw new SyncException($"поле '{name}' должно быть числом"); } private static void RequireGuid(JsonElement d, string name) { if (!d.TryGetProperty(name, out var el) || !Guid.TryParse(el.GetString(), out _)) throw new SyncException($"поле '{name}' отсутствует или не uuid"); } private static void RequireDate(JsonElement d, string name) { if (!d.TryGetProperty(name, out var el) || !DateTime.TryParse(el.GetString(), out _)) throw new SyncException($"поле '{name}' отсутствует или не дата"); } private static void OneOf(JsonElement d, string name, bool required, params string[] allowed) { if (!d.TryGetProperty(name, out var el) || el.ValueKind != JsonValueKind.String) { if (required) throw new SyncException($"поле '{name}' обязательно"); return; } var value = el.GetString(); if (value != null && !allowed.Contains(value)) throw new SyncException($"поле '{name}' = '{value}' вне допустимых значений"); } private static void Range(JsonElement d, string name, int min, int max) { if (!d.TryGetProperty(name, out var el)) return; if (el.ValueKind == JsonValueKind.Null) return; if (el.ValueKind != JsonValueKind.Number || !el.TryGetInt32(out var value) || value < min || value > max) throw new SyncException($"поле '{name}' вне диапазона {min}–{max}"); } public static DateTime? UtcDateOrNull(JsonElement d, string name) { if (!d.TryGetProperty(name, out var el) || el.ValueKind != JsonValueKind.String) return null; var parsed = DateTime.Parse(el.GetString()!, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind); return parsed.ToUniversalTime(); } public static DateOnly? DayOrNull(JsonElement d, string name) { if (!d.TryGetProperty(name, out var el) || el.ValueKind != JsonValueKind.String) return null; return DateOnly.Parse(el.GetString()!, CultureInfo.InvariantCulture); } public static int? IntOrNull(JsonElement d, string name) { if (!d.TryGetProperty(name, out var el) || el.ValueKind != JsonValueKind.Number) return null; return el.GetInt32(); } public static decimal? DecimalOrNull(JsonElement d, string name) { if (!d.TryGetProperty(name, out var el) || el.ValueKind != JsonValueKind.Number) return null; return el.GetDecimal(); } public static bool? BoolOrNull(JsonElement d, string name) { if (!d.TryGetProperty(name, out var el) || el.ValueKind != JsonValueKind.True && el.ValueKind != JsonValueKind.False) return null; return el.GetBoolean(); } public static string? StringOrNull(JsonElement d, string name) { if (!d.TryGetProperty(name, out var el) || el.ValueKind != JsonValueKind.String) return null; return el.GetString(); } }