VEU.WathBody/3-app/2-cli/Server.WatchBody.Web/Dto/SyncDtos.cs

207 lines
8.4 KiB
C#
Raw 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.Globalization;
using System.Text.Json;
namespace Server.WatchBody.Web.Dto;
/// <summary>Ошибка обработки пакета sync → HTTP-ответ с кодом и телом {"error": ...}.</summary>
public class SyncException(string message, int statusCode = 400, string code = "bad_request") : Exception(message)
{
public int StatusCode { get; } = statusCode;
public string Code { get; } = code;
}
/// <summary>Запись пакета sync (tech-requirements 2.1).</summary>
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<SyncItem> 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<SyncItem>();
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();
}
}