207 lines
8.4 KiB
C#
207 lines
8.4 KiB
C#
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();
|
||
}
|
||
}
|