264 lines
9.8 KiB
C#
264 lines
9.8 KiB
C#
using System.IO;
|
|
using System.Net;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using UtopiaCanteenSystem.Models;
|
|
using UtopiaCanteenSystem.Services;
|
|
|
|
namespace UtopiaCanteenSystem.Api;
|
|
|
|
/// <summary>
|
|
/// Self-hosted HTTP API on the central server PC for scanner clients (HttpListener, no shared SQLite over network).
|
|
/// </summary>
|
|
public static class CanteenLocalApiHost
|
|
{
|
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
|
{
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
|
PropertyNameCaseInsensitive = true
|
|
};
|
|
|
|
public static async Task RunAsync(RfidService rfidService, string listenUrls, CancellationToken cancellationToken)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(listenUrls))
|
|
listenUrls = "http://0.0.0.0:5000/";
|
|
|
|
var prefixes = listenUrls
|
|
.Split(new[] { ';', ',' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
|
.Select(NormalizePrefix)
|
|
.Where(p => !string.IsNullOrEmpty(p))
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.ToArray();
|
|
|
|
if (prefixes.Length == 0)
|
|
prefixes = new[] { "http://+:5000/" };
|
|
|
|
using var listener = new HttpListener();
|
|
foreach (var prefix in prefixes)
|
|
listener.Prefixes.Add(prefix);
|
|
|
|
listener.Start();
|
|
Logger.Log(new Exception($"Canteen local API listening: {string.Join(", ", prefixes)}"), "CanteenLocalApiHost");
|
|
|
|
try
|
|
{
|
|
while (!cancellationToken.IsCancellationRequested)
|
|
{
|
|
HttpListenerContext ctx;
|
|
try
|
|
{
|
|
ctx = await listener.GetContextAsync().ConfigureAwait(false);
|
|
}
|
|
catch (HttpListenerException) when (cancellationToken.IsCancellationRequested)
|
|
{
|
|
break;
|
|
}
|
|
catch (ObjectDisposedException)
|
|
{
|
|
break;
|
|
}
|
|
|
|
_ = Task.Run(() => ProcessRequestAsync(ctx, rfidService), cancellationToken);
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
listener.Stop();
|
|
listener.Close();
|
|
}
|
|
}
|
|
|
|
private static async Task ProcessRequestAsync(HttpListenerContext ctx, RfidService rfid)
|
|
{
|
|
try
|
|
{
|
|
AddCorsHeaders(ctx.Response);
|
|
if (string.Equals(ctx.Request.HttpMethod, "OPTIONS", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
ctx.Response.StatusCode = 204;
|
|
ctx.Response.Close();
|
|
return;
|
|
}
|
|
|
|
var path = ctx.Request.Url?.AbsolutePath?.TrimEnd('/') ?? string.Empty;
|
|
var method = ctx.Request.HttpMethod ?? "GET";
|
|
|
|
if (method == "POST" && path.Equals("/api/rfid/scan", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
await HandleScanAsync(ctx, rfid).ConfigureAwait(false);
|
|
return;
|
|
}
|
|
|
|
if (method == "GET")
|
|
{
|
|
if (path.Equals("/api/rfid/scans/today", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
await WriteJsonAsync(ctx, rfid.GetScansForToday().Select(ScanRecordDto.FromEntity).ToList()).ConfigureAwait(false);
|
|
return;
|
|
}
|
|
|
|
if (path.Equals("/api/rfid/scans/last", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
var last = rfid.GetLastScan();
|
|
await WriteJsonAsync(ctx, last == null ? null : ScanRecordDto.FromEntity(last)).ConfigureAwait(false);
|
|
return;
|
|
}
|
|
|
|
if (path.StartsWith("/api/rfid/scans/recent", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
var count = ParseQueryInt(ctx.Request.Url?.Query, "count", 4);
|
|
var list = rfid.GetLastScans(count).Select(ScanRecordDto.FromEntity).ToList();
|
|
await WriteJsonAsync(ctx, list).ConfigureAwait(false);
|
|
return;
|
|
}
|
|
|
|
if (path.Equals("/api/rfid/stats/today", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
var n = await rfid.GetTodayScanCountAsync().ConfigureAwait(false);
|
|
await WriteJsonAsync(ctx, new { count = n }).ConfigureAwait(false);
|
|
return;
|
|
}
|
|
|
|
if (path.Equals("/api/rfid/stats/total", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
var n = await rfid.GetTotalScanCountAsync().ConfigureAwait(false);
|
|
await WriteJsonAsync(ctx, new { count = n }).ConfigureAwait(false);
|
|
return;
|
|
}
|
|
|
|
if (path.StartsWith("/api/rfid/stats/today-for-card", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
var cardId = ParseQueryString(ctx.Request.Url?.Query, "cardId");
|
|
var n = await rfid.GetTodayScanCountForCardAsync(cardId ?? string.Empty).ConfigureAwait(false);
|
|
await WriteJsonAsync(ctx, new { count = n }).ConfigureAwait(false);
|
|
return;
|
|
}
|
|
|
|
if (path.StartsWith("/api/rfid/stats/total-for-card", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
var cardId = ParseQueryString(ctx.Request.Url?.Query, "cardId");
|
|
var n = await rfid.GetTotalScanCountForCardAsync(cardId ?? string.Empty).ConfigureAwait(false);
|
|
await WriteJsonAsync(ctx, new { count = n }).ConfigureAwait(false);
|
|
return;
|
|
}
|
|
}
|
|
|
|
await WriteJsonAsync(ctx, new { message = "Not found" }, 404).ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.Log(ex, "CanteenLocalApiHost.ProcessRequestAsync");
|
|
try
|
|
{
|
|
await WriteJsonAsync(ctx, new { message = ex.Message }, 500).ConfigureAwait(false);
|
|
}
|
|
catch
|
|
{
|
|
// Ignore
|
|
}
|
|
}
|
|
}
|
|
|
|
private static async Task HandleScanAsync(HttpListenerContext ctx, RfidService rfid)
|
|
{
|
|
RfidScanRequestDto? dto;
|
|
using (var reader = new StreamReader(ctx.Request.InputStream, ctx.Request.ContentEncoding))
|
|
{
|
|
var body = await reader.ReadToEndAsync().ConfigureAwait(false);
|
|
if (string.IsNullOrWhiteSpace(body))
|
|
{
|
|
await WriteJsonAsync(ctx, new { message = "Request body required." }, 400).ConfigureAwait(false);
|
|
return;
|
|
}
|
|
|
|
dto = JsonSerializer.Deserialize<RfidScanRequestDto>(body, JsonOptions);
|
|
}
|
|
|
|
if (dto == null || string.IsNullOrWhiteSpace(dto.CardId))
|
|
{
|
|
await WriteJsonAsync(ctx, new { message = "cardId is required." }, 400).ConfigureAwait(false);
|
|
return;
|
|
}
|
|
|
|
var remoteIp = ctx.Request.RemoteEndPoint?.Address?.ToString() ?? string.Empty;
|
|
if (remoteIp == "::1")
|
|
remoteIp = "127.0.0.1";
|
|
|
|
var ip = string.IsNullOrWhiteSpace(dto.IpAddress) ? remoteIp : dto.IpAddress!.Trim();
|
|
|
|
var clientCtx = new RfidScanClientContext
|
|
{
|
|
DeviceId = dto.DeviceId?.Trim() ?? string.Empty,
|
|
SiteId = dto.SiteId?.Trim() ?? string.Empty,
|
|
IpAddress = ip
|
|
};
|
|
|
|
var result = rfid.ProcessScanDetailed(dto.CardId.Trim(), clientCtx);
|
|
string? mealLabel = null;
|
|
string? mealItems = null;
|
|
double totalPrice = 0;
|
|
if (result.Success)
|
|
{
|
|
var last = rfid.GetLastScan();
|
|
if (last != null)
|
|
{
|
|
mealLabel = last.MealLabel;
|
|
mealItems = last.MealItems;
|
|
totalPrice = last.TotalPrice;
|
|
}
|
|
}
|
|
|
|
var response = RfidScanResponseDto.FromScanResult(result, mealLabel, mealItems, totalPrice);
|
|
await WriteJsonAsync(ctx, response).ConfigureAwait(false);
|
|
}
|
|
|
|
private static void AddCorsHeaders(HttpListenerResponse response)
|
|
{
|
|
response.Headers.Add("Access-Control-Allow-Origin", "*");
|
|
response.Headers.Add("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
response.Headers.Add("Access-Control-Allow-Headers", "Content-Type");
|
|
}
|
|
|
|
private static async Task WriteJsonAsync(HttpListenerContext ctx, object? payload, int statusCode = 200)
|
|
{
|
|
var json = JsonSerializer.Serialize(payload, JsonOptions);
|
|
var bytes = Encoding.UTF8.GetBytes(json);
|
|
ctx.Response.StatusCode = statusCode;
|
|
ctx.Response.ContentType = "application/json; charset=utf-8";
|
|
ctx.Response.ContentLength64 = bytes.Length;
|
|
AddCorsHeaders(ctx.Response);
|
|
await ctx.Response.OutputStream.WriteAsync(bytes).ConfigureAwait(false);
|
|
ctx.Response.Close();
|
|
}
|
|
|
|
private static string NormalizePrefix(string url)
|
|
{
|
|
var u = url.Trim();
|
|
if (!u.EndsWith('/'))
|
|
u += "/";
|
|
if (u.Contains("0.0.0.0", StringComparison.Ordinal))
|
|
u = u.Replace("0.0.0.0", "+", StringComparison.Ordinal);
|
|
return u;
|
|
}
|
|
|
|
private static int ParseQueryInt(string? query, string key, int defaultValue)
|
|
{
|
|
var s = ParseQueryString(query, key);
|
|
return int.TryParse(s, out var n) ? n : defaultValue;
|
|
}
|
|
|
|
private static string? ParseQueryString(string? query, string key)
|
|
{
|
|
if (string.IsNullOrEmpty(query))
|
|
return null;
|
|
var q = query.TrimStart('?');
|
|
foreach (var part in q.Split('&', StringSplitOptions.RemoveEmptyEntries))
|
|
{
|
|
var kv = part.Split('=', 2);
|
|
if (kv.Length > 0 && string.Equals(Uri.UnescapeDataString(kv[0]), key, StringComparison.OrdinalIgnoreCase))
|
|
return kv.Length > 1 ? Uri.UnescapeDataString(kv[1]) : string.Empty;
|
|
}
|
|
return null;
|
|
}
|
|
}
|