Wetterdienst
This commit is contained in:
@@ -0,0 +1,266 @@
|
||||
using System.Globalization;
|
||||
using System.IO.Compression;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Xml.Linq;
|
||||
using LehrerApp.Core.Models;
|
||||
|
||||
namespace LehrerApp.Api;
|
||||
|
||||
/// <summary>Lädt DWD-MOSMIX-Punktvorhersagen und CAP-Warnungen. Alle externen Antworten werden
|
||||
/// pro Serverinstanz gecacht; pro Benutzer wird zusätzlich der letzte erfolgreiche Snapshot im
|
||||
/// SchoolWeatherStore persistiert.</summary>
|
||||
public sealed class DwdWeatherService(HttpClient http)
|
||||
{
|
||||
private const string StationCatalogUrl =
|
||||
"https://www.dwd.de/DE/leistungen/met_verfahren_mosmix/mosmix_stationskatalog.cfg?view=nasPublication";
|
||||
private const string ForecastUrl =
|
||||
"https://opendata.dwd.de/weather/local_forecasts/mos/MOSMIX_L/single_stations/{0}/kml/MOSMIX_L_LATEST_{0}.kmz";
|
||||
private const string WarningsUrl =
|
||||
"https://opendata.dwd.de/weather/alerts/cap/COMMUNEUNION_DWD_STAT/Z_CAP_C_EDZW_LATEST_PVW_STATUS_PREMIUMDWD_COMMUNEUNION_DE.zip";
|
||||
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
private List<MosmixStation>? _stations;
|
||||
private DateTime _stationsLoadedAt;
|
||||
private readonly Dictionary<string, CachedForecast> _forecastCache = [];
|
||||
private CachedWarnings? _warningsCache;
|
||||
|
||||
public async Task<WeatherSnapshot> GetAsync(SchoolLocationProfile location,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await _gate.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
var stations = await GetStationsAsync(cancellationToken);
|
||||
var station = FindNearestStation(stations, location.Latitude, location.Longitude)
|
||||
?? throw new InvalidOperationException("Der DWD-Stationskatalog enthält keine Stationen.");
|
||||
var forecast = await GetForecastAsync(station, cancellationToken);
|
||||
var warnings = await GetWarningsAsync(cancellationToken);
|
||||
|
||||
return new WeatherSnapshot
|
||||
{
|
||||
StationId = station.Id,
|
||||
StationName = station.Name,
|
||||
StationDistanceKm = Math.Round(DistanceKm(
|
||||
location.Latitude, location.Longitude, station.Latitude, station.Longitude), 1),
|
||||
ForecastIssuedAt = forecast.IssuedAt,
|
||||
RetrievedAt = DateTime.UtcNow,
|
||||
Forecast = forecast.Hours,
|
||||
Warnings = warnings.Where(w => Contains(w.Polygons, location.Latitude, location.Longitude))
|
||||
.Select(w => w.Warning)
|
||||
.OrderByDescending(w => SeverityRank(w.Severity))
|
||||
.ThenBy(w => w.Onset)
|
||||
.ToList(),
|
||||
};
|
||||
}
|
||||
finally { _gate.Release(); }
|
||||
}
|
||||
|
||||
private async Task<List<MosmixStation>> GetStationsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_stations is not null && DateTime.UtcNow - _stationsLoadedAt < TimeSpan.FromDays(7))
|
||||
return _stations;
|
||||
var text = await http.GetStringAsync(StationCatalogUrl, cancellationToken);
|
||||
_stations = ParseStationCatalog(text);
|
||||
_stationsLoadedAt = DateTime.UtcNow;
|
||||
return _stations;
|
||||
}
|
||||
|
||||
private async Task<ParsedForecast> GetForecastAsync(MosmixStation station,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (_forecastCache.TryGetValue(station.Id, out var cached) &&
|
||||
DateTime.UtcNow - cached.LoadedAt < TimeSpan.FromMinutes(45))
|
||||
return cached.Forecast;
|
||||
|
||||
var bytes = await http.GetByteArrayAsync(string.Format(CultureInfo.InvariantCulture,
|
||||
ForecastUrl, station.Id), cancellationToken);
|
||||
var parsed = ParseMosmix(bytes);
|
||||
_forecastCache[station.Id] = new(DateTime.UtcNow, parsed);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
private async Task<List<ParsedWarning>> GetWarningsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_warningsCache is not null &&
|
||||
DateTime.UtcNow - _warningsCache.LoadedAt < TimeSpan.FromMinutes(5))
|
||||
return _warningsCache.Warnings;
|
||||
var bytes = await http.GetByteArrayAsync(WarningsUrl, cancellationToken);
|
||||
var warnings = ParseCapArchive(bytes, DateTime.UtcNow);
|
||||
_warningsCache = new(DateTime.UtcNow, warnings);
|
||||
return warnings;
|
||||
}
|
||||
|
||||
public static List<MosmixStation> ParseStationCatalog(string text)
|
||||
{
|
||||
var regex = new Regex(@"^(\S+)\s+\S+\s+(.{20})\s+(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)\s+", RegexOptions.Compiled);
|
||||
var result = new List<MosmixStation>();
|
||||
foreach (var line in text.Split('\n'))
|
||||
{
|
||||
var match = regex.Match(line.TrimEnd('\r'));
|
||||
if (!match.Success ||
|
||||
!double.TryParse(match.Groups[3].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var lat) ||
|
||||
!double.TryParse(match.Groups[4].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var lon))
|
||||
continue;
|
||||
result.Add(new(match.Groups[1].Value, match.Groups[2].Value.Trim(), lat, lon));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public static MosmixStation? FindNearestStation(IEnumerable<MosmixStation> stations,
|
||||
double latitude, double longitude) => stations.MinBy(s =>
|
||||
DistanceKm(latitude, longitude, s.Latitude, s.Longitude));
|
||||
|
||||
public static ParsedForecast ParseMosmix(byte[] kmz)
|
||||
{
|
||||
using var memory = new MemoryStream(kmz);
|
||||
using var archive = new ZipArchive(memory, ZipArchiveMode.Read);
|
||||
var entry = archive.Entries.FirstOrDefault(x => x.Name.EndsWith(".kml", StringComparison.OrdinalIgnoreCase))
|
||||
?? throw new InvalidDataException("Die MOSMIX-Datei enthält kein KML-Dokument.");
|
||||
using var stream = entry.Open();
|
||||
var document = XDocument.Load(stream);
|
||||
XNamespace dwd = "https://opendata.dwd.de/weather/lib/pointforecast_dwd_extension_V1_0.xsd";
|
||||
|
||||
var times = document.Descendants(dwd + "TimeStep")
|
||||
.Select(x => ParseDate(x.Value))
|
||||
.Where(x => x.HasValue)
|
||||
.Select(x => x!.Value)
|
||||
.ToList();
|
||||
if (times.Count == 0) throw new InvalidDataException("MOSMIX enthält keine Zeitschritte.");
|
||||
|
||||
var values = document.Descendants(dwd + "Forecast")
|
||||
.Where(x => x.Attribute(dwd + "elementName") is not null)
|
||||
.ToDictionary(
|
||||
x => x.Attribute(dwd + "elementName")!.Value,
|
||||
x => ParseValues(x.Element(dwd + "value")?.Value ?? ""),
|
||||
StringComparer.Ordinal);
|
||||
|
||||
double? Value(string key, int i) => values.TryGetValue(key, out var series) && i < series.Count
|
||||
? series[i] : null;
|
||||
var hours = new List<WeatherForecastHour>();
|
||||
var earliest = DateTime.UtcNow.AddHours(-1);
|
||||
for (var i = 0; i < times.Count && hours.Count < 72; i++)
|
||||
{
|
||||
if (times[i] < earliest) continue;
|
||||
hours.Add(new WeatherForecastHour
|
||||
{
|
||||
ValidAt = times[i],
|
||||
TemperatureC = Round(Value("TTT", i) - 273.15, 1),
|
||||
WindSpeedKmh = Round(Value("FF", i) * 3.6, 1),
|
||||
WindGustKmh = Round(Value("FX1", i) * 3.6, 1),
|
||||
PrecipitationMm = Round(Value("RR1c", i), 1),
|
||||
CloudCoverPercent = Round(Value("Neff", i), 0),
|
||||
WeatherCode = Value("ww", i) is { } code ? (int)Math.Round(code) : null,
|
||||
});
|
||||
}
|
||||
|
||||
return new(ParseDate(document.Descendants(dwd + "IssueTime").FirstOrDefault()?.Value), hours);
|
||||
}
|
||||
|
||||
public static List<ParsedWarning> ParseCapArchive(byte[] zip, DateTime nowUtc)
|
||||
{
|
||||
var result = new List<ParsedWarning>();
|
||||
using var memory = new MemoryStream(zip);
|
||||
using var archive = new ZipArchive(memory, ZipArchiveMode.Read);
|
||||
foreach (var entry in archive.Entries.Where(x => x.Name.EndsWith(".xml", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
using var stream = entry.Open();
|
||||
var document = XDocument.Load(stream);
|
||||
XNamespace cap = "urn:oasis:names:tc:emergency:cap:1.2";
|
||||
var root = document.Root;
|
||||
if (root is null || root.Element(cap + "status")?.Value != "Actual") continue;
|
||||
var info = root.Elements(cap + "info").FirstOrDefault(x =>
|
||||
x.Element(cap + "language")?.Value.StartsWith("de", StringComparison.OrdinalIgnoreCase) == true)
|
||||
?? root.Elements(cap + "info").FirstOrDefault();
|
||||
if (info is null) continue;
|
||||
var expires = ParseDate(info.Element(cap + "expires")?.Value);
|
||||
if (expires is { } until && until < nowUtc) continue;
|
||||
|
||||
var polygons = info.Elements(cap + "area")
|
||||
.SelectMany(x => x.Elements(cap + "polygon"))
|
||||
.Select(x => ParsePolygon(x.Value))
|
||||
.Where(x => x.Count >= 3)
|
||||
.ToList();
|
||||
// Warngebiete ohne Polygon (z.B. Seegebiete) können einer Schulkoordinate nicht
|
||||
// zuverlässig zugeordnet werden und werden daher bewusst nicht global angezeigt.
|
||||
if (polygons.Count == 0) continue;
|
||||
result.Add(new ParsedWarning(new WeatherWarning
|
||||
{
|
||||
Identifier = root.Element(cap + "identifier")?.Value ?? "",
|
||||
Event = info.Element(cap + "event")?.Value ?? "",
|
||||
Headline = info.Element(cap + "headline")?.Value ?? "",
|
||||
Description = info.Element(cap + "description")?.Value ?? "",
|
||||
Instruction = info.Element(cap + "instruction")?.Value ?? "",
|
||||
Severity = info.Element(cap + "severity")?.Value ?? "Unknown",
|
||||
Onset = ParseDate(info.Element(cap + "onset")?.Value),
|
||||
Expires = expires,
|
||||
}, polygons));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static List<double?> ParseValues(string raw) => raw
|
||||
.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(x => double.TryParse(x, NumberStyles.Float, CultureInfo.InvariantCulture, out var value)
|
||||
? (double?)value : null)
|
||||
.ToList();
|
||||
|
||||
private static List<GeoPoint> ParsePolygon(string raw) => raw
|
||||
.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(x => x.Split(','))
|
||||
.Where(x => x.Length >= 2 &&
|
||||
double.TryParse(x[0], NumberStyles.Float, CultureInfo.InvariantCulture, out _) &&
|
||||
double.TryParse(x[1], NumberStyles.Float, CultureInfo.InvariantCulture, out _))
|
||||
.Select(x => new GeoPoint(
|
||||
double.Parse(x[0], CultureInfo.InvariantCulture),
|
||||
double.Parse(x[1], CultureInfo.InvariantCulture)))
|
||||
.ToList();
|
||||
|
||||
public static bool PointInPolygon(IReadOnlyList<GeoPoint> polygon, double latitude, double longitude)
|
||||
{
|
||||
var inside = false;
|
||||
for (int i = 0, j = polygon.Count - 1; i < polygon.Count; j = i++)
|
||||
{
|
||||
var a = polygon[i];
|
||||
var b = polygon[j];
|
||||
if ((a.Latitude > latitude) != (b.Latitude > latitude) &&
|
||||
longitude < (b.Longitude - a.Longitude) * (latitude - a.Latitude) /
|
||||
(b.Latitude - a.Latitude) + a.Longitude)
|
||||
inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
private static bool Contains(IEnumerable<IReadOnlyList<GeoPoint>> polygons,
|
||||
double latitude, double longitude) => polygons.Any(p => PointInPolygon(p, latitude, longitude));
|
||||
|
||||
private static double DistanceKm(double lat1, double lon1, double lat2, double lon2)
|
||||
{
|
||||
const double earthRadiusKm = 6371.0;
|
||||
static double Rad(double degrees) => degrees * Math.PI / 180.0;
|
||||
var dLat = Rad(lat2 - lat1);
|
||||
var dLon = Rad(lon2 - lon1);
|
||||
var a = Math.Sin(dLat / 2) * Math.Sin(dLat / 2) +
|
||||
Math.Cos(Rad(lat1)) * Math.Cos(Rad(lat2)) *
|
||||
Math.Sin(dLon / 2) * Math.Sin(dLon / 2);
|
||||
return earthRadiusKm * 2 * Math.Atan2(Math.Sqrt(a), Math.Sqrt(1 - a));
|
||||
}
|
||||
|
||||
private static DateTime? ParseDate(string? value) =>
|
||||
DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AllowWhiteSpaces,
|
||||
out var parsed) ? parsed.UtcDateTime : null;
|
||||
|
||||
private static double? Round(double? value, int digits) => value is null
|
||||
? null : Math.Round(value.Value, digits, MidpointRounding.AwayFromZero);
|
||||
|
||||
private static int SeverityRank(string severity) => severity switch
|
||||
{
|
||||
"Extreme" => 4, "Severe" => 3, "Moderate" => 2, "Minor" => 1, _ => 0,
|
||||
};
|
||||
|
||||
public sealed record MosmixStation(string Id, string Name, double Latitude, double Longitude);
|
||||
public sealed record ParsedForecast(DateTime? IssuedAt, List<WeatherForecastHour> Hours);
|
||||
public sealed record GeoPoint(double Latitude, double Longitude);
|
||||
public sealed record ParsedWarning(WeatherWarning Warning, List<List<GeoPoint>> Polygons);
|
||||
private sealed record CachedForecast(DateTime LoadedAt, ParsedForecast Forecast);
|
||||
private sealed record CachedWarnings(DateTime LoadedAt, List<ParsedWarning> Warnings);
|
||||
}
|
||||
@@ -149,6 +149,123 @@ public static class Endpoints
|
||||
});
|
||||
}
|
||||
|
||||
// ── Schulstandort und DWD-Wetter ─────────────────────────────────────────
|
||||
|
||||
public static void MapSchoolWeatherEndpoints(this WebApplication app)
|
||||
{
|
||||
var g = app.MapGroup("/api/school").RequireAuthorization();
|
||||
|
||||
g.MapGet("/location", (ClaimsPrincipal user, SchoolWeatherStore store) =>
|
||||
{
|
||||
var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
if (uid is null) return Results.Unauthorized();
|
||||
var location = store.GetLocation(uid);
|
||||
return location is null
|
||||
? Results.NotFound("Noch kein Schulstandort hinterlegt.")
|
||||
: Results.Ok(location);
|
||||
});
|
||||
|
||||
g.MapPut("/location", async ([FromBody] SchoolLocationRequest request,
|
||||
ClaimsPrincipal user, SchoolWeatherStore store, IGeocoder geocoder,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
if (uid is null) return Results.Unauthorized();
|
||||
|
||||
var errors = ValidateLocation(request);
|
||||
if (errors.Count > 0) return Results.ValidationProblem(errors);
|
||||
|
||||
var existing = store.GetLocation(uid);
|
||||
if (existing is not null && SameLocation(existing, request))
|
||||
return Results.Ok(existing);
|
||||
|
||||
GeocodingResult? result;
|
||||
try { result = await geocoder.GeocodeAsync(request, cancellationToken); }
|
||||
catch (HttpRequestException)
|
||||
{
|
||||
return Results.Problem("Der Geocoding-Dienst ist momentan nicht erreichbar.",
|
||||
statusCode: StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
catch (TaskCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return Results.Problem("Die Adressprüfung hat zu lange gedauert.",
|
||||
statusCode: StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
if (result is null)
|
||||
return Results.UnprocessableEntity(new
|
||||
{
|
||||
detail = "Die Schuladresse konnte nicht eindeutig gefunden werden. Bitte Schreibweise, PLZ und Ort prüfen.",
|
||||
});
|
||||
|
||||
var profile = new SchoolLocationProfile
|
||||
{
|
||||
SchoolName = request.SchoolName.Trim(),
|
||||
Street = request.Street.Trim(),
|
||||
PostalCode = request.PostalCode.Trim(),
|
||||
City = request.City.Trim(),
|
||||
State = request.State,
|
||||
Latitude = result.Latitude,
|
||||
Longitude = result.Longitude,
|
||||
ResolvedAddress = result.DisplayName,
|
||||
UpdatedAt = DateTime.UtcNow,
|
||||
};
|
||||
store.SaveLocation(uid, profile);
|
||||
return Results.Ok(profile);
|
||||
});
|
||||
|
||||
g.MapGet("/weather", async (ClaimsPrincipal user, SchoolWeatherStore store,
|
||||
DwdWeatherService weather, CancellationToken cancellationToken) =>
|
||||
{
|
||||
var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
if (uid is null) return Results.Unauthorized();
|
||||
var location = store.GetLocation(uid);
|
||||
if (location is null) return Results.NotFound("Noch kein Schulstandort hinterlegt.");
|
||||
|
||||
var cached = store.GetWeather(uid);
|
||||
if (cached is not null && DateTime.UtcNow - cached.RetrievedAt.ToUniversalTime() < TimeSpan.FromMinutes(5))
|
||||
return Results.Ok(cached);
|
||||
try
|
||||
{
|
||||
var current = await weather.GetAsync(location, cancellationToken);
|
||||
store.SaveWeather(uid, current);
|
||||
return Results.Ok(current);
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or InvalidDataException or InvalidOperationException)
|
||||
{
|
||||
if (cached is not null)
|
||||
{
|
||||
cached.IsStale = true;
|
||||
return Results.Ok(cached);
|
||||
}
|
||||
return Results.Problem("Der DWD ist momentan nicht erreichbar und es liegt noch kein Wetter-Cache vor.",
|
||||
statusCode: StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static Dictionary<string, string[]> ValidateLocation(SchoolLocationRequest request)
|
||||
{
|
||||
var errors = new Dictionary<string, string[]>();
|
||||
if (string.IsNullOrWhiteSpace(request.SchoolName))
|
||||
errors[nameof(request.SchoolName)] = ["Bitte den Namen der Schule angeben."];
|
||||
if (string.IsNullOrWhiteSpace(request.Street))
|
||||
errors[nameof(request.Street)] = ["Bitte Straße und Hausnummer angeben."];
|
||||
if (!System.Text.RegularExpressions.Regex.IsMatch(request.PostalCode?.Trim() ?? "", @"^\d{5}$"))
|
||||
errors[nameof(request.PostalCode)] = ["Bitte eine fünfstellige deutsche PLZ angeben."];
|
||||
if (string.IsNullOrWhiteSpace(request.City))
|
||||
errors[nameof(request.City)] = ["Bitte den Ort angeben."];
|
||||
if (!Enum.IsDefined(request.State))
|
||||
errors[nameof(request.State)] = ["Das Bundesland ist ungültig."];
|
||||
return errors;
|
||||
}
|
||||
|
||||
private static bool SameLocation(SchoolLocationProfile existing, SchoolLocationRequest request) =>
|
||||
string.Equals(existing.SchoolName, request.SchoolName.Trim(), StringComparison.OrdinalIgnoreCase) &&
|
||||
string.Equals(existing.Street, request.Street.Trim(), StringComparison.OrdinalIgnoreCase) &&
|
||||
string.Equals(existing.PostalCode, request.PostalCode.Trim(), StringComparison.Ordinal) &&
|
||||
string.Equals(existing.City, request.City.Trim(), StringComparison.OrdinalIgnoreCase) &&
|
||||
existing.State == request.State;
|
||||
|
||||
// ── JWT ───────────────────────────────────────────────────────────────────
|
||||
|
||||
private static string Jwt(string userId, string secret)
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
using System.Globalization;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using LehrerApp.Core.Models;
|
||||
|
||||
namespace LehrerApp.Api;
|
||||
|
||||
public interface IGeocoder
|
||||
{
|
||||
Task<GeocodingResult?> GeocodeAsync(SchoolLocationRequest address, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed record GeocodingResult(double Latitude, double Longitude, string DisplayName);
|
||||
|
||||
/// <summary>Einmalige, explizit ausgelöste Adressauflösung über den öffentlichen OSM-Nominatim-
|
||||
/// Dienst. Kein Autocomplete; die dauerhafte Ergebnisspeicherung übernimmt SchoolWeatherStore.</summary>
|
||||
public sealed class NominatimGeocoder(HttpClient http) : IGeocoder
|
||||
{
|
||||
private static readonly SemaphoreSlim RequestGate = new(1, 1);
|
||||
private static DateTime _lastRequestAt = DateTime.MinValue;
|
||||
|
||||
public async Task<GeocodingResult?> GeocodeAsync(SchoolLocationRequest address,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var structured = Query(new Dictionary<string, string>
|
||||
{
|
||||
["street"] = address.Street,
|
||||
["postalcode"] = address.PostalCode,
|
||||
["city"] = address.City,
|
||||
});
|
||||
var result = await SearchRespectingRateLimitAsync(structured, cancellationToken);
|
||||
if (result is not null) return result;
|
||||
|
||||
// Manche Schulen/Ortsteile sind bei OSM anders adressiert. Ein kontrollierter zweiter
|
||||
// Versuch mit der gesamten Suchzeile ist robuster, bleibt aber weit weg vom 1-rps-Limit.
|
||||
var freeForm = string.Join(", ", new[]
|
||||
{
|
||||
address.SchoolName, address.Street, $"{address.PostalCode} {address.City}", "Deutschland",
|
||||
}.Where(x => !string.IsNullOrWhiteSpace(x)));
|
||||
return await SearchRespectingRateLimitAsync(
|
||||
Query(new Dictionary<string, string> { ["q"] = freeForm }), cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<GeocodingResult?> SearchRespectingRateLimitAsync(string query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await RequestGate.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
var wait = TimeSpan.FromSeconds(1) - (DateTime.UtcNow - _lastRequestAt);
|
||||
if (wait > TimeSpan.Zero) await Task.Delay(wait, cancellationToken);
|
||||
_lastRequestAt = DateTime.UtcNow;
|
||||
var candidates = await http.GetFromJsonAsync<List<NominatimCandidate>>(
|
||||
$"search?format=jsonv2&limit=1&countrycodes=de{query}", cancellationToken);
|
||||
var candidate = candidates?.FirstOrDefault();
|
||||
if (candidate is null ||
|
||||
!double.TryParse(candidate.Lat, NumberStyles.Float, CultureInfo.InvariantCulture, out var latitude) ||
|
||||
!double.TryParse(candidate.Lon, NumberStyles.Float, CultureInfo.InvariantCulture, out var longitude))
|
||||
return null;
|
||||
return new(latitude, longitude, candidate.DisplayName ?? "");
|
||||
}
|
||||
finally { RequestGate.Release(); }
|
||||
}
|
||||
|
||||
private static string Query(IReadOnlyDictionary<string, string> values) => string.Concat(
|
||||
values.Where(x => !string.IsNullOrWhiteSpace(x.Value))
|
||||
.Select(x => $"&{Uri.EscapeDataString(x.Key)}={Uri.EscapeDataString(x.Value.Trim())}"));
|
||||
|
||||
private sealed class NominatimCandidate
|
||||
{
|
||||
[JsonPropertyName("lat")]
|
||||
public string? Lat { get; set; }
|
||||
[JsonPropertyName("lon")]
|
||||
public string? Lon { get; set; }
|
||||
[JsonPropertyName("display_name")]
|
||||
public string? DisplayName { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,8 @@ if (args.Length > 0 && args[0] == "set-password")
|
||||
|
||||
var secret = builder.Configuration["JWT_SECRET"]
|
||||
?? throw new InvalidOperationException("JWT_SECRET nicht konfiguriert.");
|
||||
var outboundUserAgent = builder.Configuration["Geocoding:UserAgent"]
|
||||
?? "LehrerApp-Server/1.0 (+https://science-teaching.de)";
|
||||
|
||||
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
.AddJwtBearer(o => o.TokenValidationParameters = new()
|
||||
@@ -81,6 +83,20 @@ builder.Services.AddSingleton<SnapshotStore>(_ => new SnapshotStore(data));
|
||||
builder.Services.AddSingleton<ReadableSnapshotStore>(_ => new ReadableSnapshotStore(data));
|
||||
builder.Services.AddSingleton<PlainEventStore>(sp =>
|
||||
new PlainEventStore(sp.GetRequiredService<EventStore>()));
|
||||
builder.Services.AddSingleton<SchoolWeatherStore>(_ => new SchoolWeatherStore(data));
|
||||
builder.Services.AddHttpClient<IGeocoder, NominatimGeocoder>(client =>
|
||||
{
|
||||
client.BaseAddress = new Uri("https://nominatim.openstreetmap.org/");
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd(outboundUserAgent);
|
||||
client.Timeout = TimeSpan.FromSeconds(15);
|
||||
});
|
||||
builder.Services.AddHttpClient("dwd", client =>
|
||||
{
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd(outboundUserAgent);
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
});
|
||||
builder.Services.AddSingleton(sp => new DwdWeatherService(
|
||||
sp.GetRequiredService<IHttpClientFactory>().CreateClient("dwd")));
|
||||
|
||||
var app = builder.Build();
|
||||
app.UseForwardedHeaders();
|
||||
@@ -95,5 +111,6 @@ app.MapAttachmentEndpoints();
|
||||
app.MapSnapshotEndpoints();
|
||||
app.MapReadableSnapshotEndpoints();
|
||||
app.MapPlainSyncEndpoints();
|
||||
app.MapSchoolWeatherEndpoints();
|
||||
app.Run();
|
||||
return 0;
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
using LehrerApp.Core.Models;
|
||||
using LiteDB;
|
||||
|
||||
namespace LehrerApp.Api;
|
||||
|
||||
/// <summary>Unverschlüsselte Serverdaten, die der Server zur Abfrage standortbezogener öffentlicher
|
||||
/// Wetterdaten benötigt. Bewusst getrennt vom clientseitig verschlüsselten EventStore.</summary>
|
||||
public sealed class SchoolWeatherStore : IDisposable
|
||||
{
|
||||
private readonly LiteDatabase _db;
|
||||
|
||||
public SchoolWeatherStore(string dataPath)
|
||||
{
|
||||
Directory.CreateDirectory(dataPath);
|
||||
_db = new LiteDatabase(Path.Combine(dataPath, "school-weather.db"));
|
||||
}
|
||||
|
||||
private ILiteCollection<LocationEntry> Locations
|
||||
{
|
||||
get
|
||||
{
|
||||
var col = _db.GetCollection<LocationEntry>("locations");
|
||||
col.EnsureIndex(x => x.UserId, unique: true);
|
||||
return col;
|
||||
}
|
||||
}
|
||||
|
||||
private ILiteCollection<WeatherEntry> Weather
|
||||
{
|
||||
get
|
||||
{
|
||||
var col = _db.GetCollection<WeatherEntry>("weather");
|
||||
col.EnsureIndex(x => x.UserId, unique: true);
|
||||
return col;
|
||||
}
|
||||
}
|
||||
|
||||
public SchoolLocationProfile? GetLocation(string userId) =>
|
||||
Locations.FindOne(x => x.UserId == userId)?.Profile;
|
||||
|
||||
public void SaveLocation(string userId, SchoolLocationProfile profile)
|
||||
{
|
||||
var existing = Locations.FindOne(x => x.UserId == userId);
|
||||
Locations.Upsert(new LocationEntry
|
||||
{
|
||||
Id = existing?.Id ?? ObjectId.NewObjectId(),
|
||||
UserId = userId,
|
||||
Profile = profile,
|
||||
});
|
||||
// Ein geänderter Standort darf niemals den Wetterstand des alten Orts liefern.
|
||||
Weather.DeleteMany(x => x.UserId == userId);
|
||||
}
|
||||
|
||||
public WeatherSnapshot? GetWeather(string userId) =>
|
||||
Weather.FindOne(x => x.UserId == userId)?.Snapshot;
|
||||
|
||||
public void SaveWeather(string userId, WeatherSnapshot snapshot)
|
||||
{
|
||||
var existing = Weather.FindOne(x => x.UserId == userId);
|
||||
Weather.Upsert(new WeatherEntry
|
||||
{
|
||||
Id = existing?.Id ?? ObjectId.NewObjectId(),
|
||||
UserId = userId,
|
||||
Snapshot = snapshot,
|
||||
});
|
||||
}
|
||||
|
||||
public void Dispose() => _db.Dispose();
|
||||
|
||||
private sealed class LocationEntry
|
||||
{
|
||||
public ObjectId Id { get; set; } = ObjectId.NewObjectId();
|
||||
public string UserId { get; set; } = "";
|
||||
public SchoolLocationProfile Profile { get; set; } = new();
|
||||
}
|
||||
|
||||
private sealed class WeatherEntry
|
||||
{
|
||||
public ObjectId Id { get; set; } = ObjectId.NewObjectId();
|
||||
public string UserId { get; set; } = "";
|
||||
public WeatherSnapshot Snapshot { get; set; } = new();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user