Add central canteen backend host
Replaces the RFID-only local API host with a broader canteen backend host and adds DTO mapping for scan, stats, cache, sync, employee, and schedule API responses.feature/centralized-offline-canteen
parent
280811eda4
commit
8bc0e215f7
|
|
@ -0,0 +1,149 @@
|
||||||
|
using UtopiaCanteen.Shared;
|
||||||
|
using UtopiaCanteenSystem.Models;
|
||||||
|
using UtopiaCanteenSystem.Services;
|
||||||
|
|
||||||
|
namespace UtopiaCanteenSystem.Api;
|
||||||
|
|
||||||
|
public static class ApiDtoMapper
|
||||||
|
{
|
||||||
|
public static EmployeeDto? ToEmployeeDto(HrmsEmployeeInfo? e)
|
||||||
|
{
|
||||||
|
if (e == null) return null;
|
||||||
|
return new EmployeeDto
|
||||||
|
{
|
||||||
|
ParentDocumentId = e.ParentDocumentId,
|
||||||
|
EmployeeId = e.EmployeeId,
|
||||||
|
UindSerial = e.UindSerial,
|
||||||
|
FunctionId = e.FunctionId,
|
||||||
|
DepartmentId = e.DepartmentId,
|
||||||
|
TagCreatedAtUtc = e.TagCreatedAtUtc,
|
||||||
|
TagCreatedBy = e.TagCreatedBy,
|
||||||
|
FirstName = e.FirstName,
|
||||||
|
MiddleName = e.MiddleName,
|
||||||
|
DepartmentTitle = e.DepartmentTitle,
|
||||||
|
DepartmentType = e.DepartmentType,
|
||||||
|
LocationSiteId = e.LocationSiteId,
|
||||||
|
GradeType = e.GradeType
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static HrmsEmployeeInfo ToModel(EmployeeDto e) => new()
|
||||||
|
{
|
||||||
|
ParentDocumentId = e.ParentDocumentId ?? string.Empty,
|
||||||
|
EmployeeId = e.EmployeeId ?? string.Empty,
|
||||||
|
UindSerial = e.UindSerial ?? string.Empty,
|
||||||
|
FunctionId = e.FunctionId,
|
||||||
|
DepartmentId = e.DepartmentId,
|
||||||
|
TagCreatedAtUtc = e.TagCreatedAtUtc,
|
||||||
|
TagCreatedBy = e.TagCreatedBy ?? string.Empty,
|
||||||
|
FirstName = e.FirstName ?? string.Empty,
|
||||||
|
MiddleName = e.MiddleName ?? string.Empty,
|
||||||
|
DepartmentTitle = e.DepartmentTitle ?? string.Empty,
|
||||||
|
DepartmentType = e.DepartmentType ?? string.Empty,
|
||||||
|
LocationSiteId = e.LocationSiteId ?? string.Empty,
|
||||||
|
GradeType = e.GradeType ?? string.Empty
|
||||||
|
};
|
||||||
|
|
||||||
|
public static ScanResponse ToScanResponse(ScanResult r, string? mealLabel = null, string? mealItems = null, double totalPrice = 0) =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Success = r.Success,
|
||||||
|
Message = r.Message,
|
||||||
|
CooldownSecondsRemaining = r.CooldownSecondsRemaining,
|
||||||
|
Employee = ToEmployeeDto(r.EmployeeInfo),
|
||||||
|
MealSession = (int)r.MealSession,
|
||||||
|
EmployeeSiteId = r.EmployeeSiteId,
|
||||||
|
CurrentSiteId = r.CurrentSiteId,
|
||||||
|
MealLabel = mealLabel,
|
||||||
|
MealItems = mealItems,
|
||||||
|
TotalPrice = totalPrice
|
||||||
|
};
|
||||||
|
|
||||||
|
public static RecentScanDto ToRecentScanDto(ScanRecord r) => new()
|
||||||
|
{
|
||||||
|
Id = r.Id,
|
||||||
|
CardId = r.CardId,
|
||||||
|
ScanTime = r.ScanTime,
|
||||||
|
IsSynced = r.IsSynced,
|
||||||
|
SiteId = r.SiteId,
|
||||||
|
DeviceId = r.DeviceId,
|
||||||
|
IpAddress = r.IpAddress,
|
||||||
|
MealSessionCode = r.MealSessionCode,
|
||||||
|
ParentDocumentId = r.ParentDocumentId,
|
||||||
|
EmployeeId = r.EmployeeId,
|
||||||
|
UindSerial = r.UindSerial,
|
||||||
|
FunctionId = r.FunctionId,
|
||||||
|
DepartmentId = r.DepartmentId,
|
||||||
|
TagCreatedAtUtc = r.TagCreatedAtUtc,
|
||||||
|
TagCreatedBy = r.TagCreatedBy,
|
||||||
|
EmployeeName = r.EmployeeName,
|
||||||
|
Department = r.Department,
|
||||||
|
DepartmentType = r.DepartmentType,
|
||||||
|
MealLabel = r.MealLabel,
|
||||||
|
MealItems = r.MealItems,
|
||||||
|
TotalPrice = r.TotalPrice,
|
||||||
|
GradeType = r.grade_type
|
||||||
|
};
|
||||||
|
|
||||||
|
public static ScanRecord ToEntity(RecentScanDto d) => new()
|
||||||
|
{
|
||||||
|
Id = d.Id,
|
||||||
|
CardId = d.CardId ?? string.Empty,
|
||||||
|
ScanTime = d.ScanTime,
|
||||||
|
IsSynced = d.IsSynced,
|
||||||
|
SiteId = d.SiteId ?? string.Empty,
|
||||||
|
DeviceId = d.DeviceId ?? string.Empty,
|
||||||
|
IpAddress = d.IpAddress ?? string.Empty,
|
||||||
|
MealSessionCode = d.MealSessionCode,
|
||||||
|
ParentDocumentId = d.ParentDocumentId ?? string.Empty,
|
||||||
|
EmployeeId = d.EmployeeId ?? string.Empty,
|
||||||
|
UindSerial = d.UindSerial ?? string.Empty,
|
||||||
|
FunctionId = d.FunctionId,
|
||||||
|
DepartmentId = d.DepartmentId,
|
||||||
|
TagCreatedAtUtc = d.TagCreatedAtUtc,
|
||||||
|
TagCreatedBy = d.TagCreatedBy ?? string.Empty,
|
||||||
|
EmployeeName = d.EmployeeName ?? string.Empty,
|
||||||
|
Department = d.Department ?? string.Empty,
|
||||||
|
DepartmentType = d.DepartmentType ?? string.Empty,
|
||||||
|
MealLabel = d.MealLabel ?? string.Empty,
|
||||||
|
MealItems = d.MealItems ?? string.Empty,
|
||||||
|
TotalPrice = d.TotalPrice,
|
||||||
|
grade_type = d.GradeType ?? string.Empty
|
||||||
|
};
|
||||||
|
|
||||||
|
public static ManualSyncResponse ToManualSync(bool success, string message, DateTime? started, DateTime? completed, string? details = null) =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Success = success,
|
||||||
|
Message = message,
|
||||||
|
StartedAt = started,
|
||||||
|
CompletedAt = completed,
|
||||||
|
Details = details
|
||||||
|
};
|
||||||
|
|
||||||
|
public static MealScheduleDto ToMealScheduleDto(MealSchedule s) => new()
|
||||||
|
{
|
||||||
|
Id = s.Id,
|
||||||
|
MealName = s.MealName,
|
||||||
|
LocationSiteId = s.LocationSiteId,
|
||||||
|
MealSession = s.MealSession,
|
||||||
|
StartTime = s.StartTime,
|
||||||
|
EndTime = s.EndTime,
|
||||||
|
IsActive = s.IsActive,
|
||||||
|
CreatedAt = s.CreatedAt,
|
||||||
|
UpdatedAt = s.UpdatedAt
|
||||||
|
};
|
||||||
|
|
||||||
|
public static MealSchedule ToMealSchedule(MealScheduleDto d) => new()
|
||||||
|
{
|
||||||
|
Id = d.Id,
|
||||||
|
MealName = d.MealName ?? string.Empty,
|
||||||
|
LocationSiteId = d.LocationSiteId ?? string.Empty,
|
||||||
|
MealSession = d.MealSession,
|
||||||
|
StartTime = d.StartTime ?? string.Empty,
|
||||||
|
EndTime = d.EndTime ?? string.Empty,
|
||||||
|
IsActive = d.IsActive,
|
||||||
|
CreatedAt = d.CreatedAt,
|
||||||
|
UpdatedAt = d.UpdatedAt
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
namespace UtopiaCanteenSystem.Api;
|
||||||
|
|
||||||
|
public sealed class HealthResponseDto
|
||||||
|
{
|
||||||
|
public string Status { get; set; } = "ok";
|
||||||
|
public string Mode { get; set; } = "server";
|
||||||
|
public DateTime Utc { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class BackendJobResponseDto
|
||||||
|
{
|
||||||
|
public bool Success { get; set; }
|
||||||
|
public string Message { get; set; } = string.Empty;
|
||||||
|
public DateTime? StartedAt { get; set; }
|
||||||
|
public DateTime? CompletedAt { get; set; }
|
||||||
|
public string? Details { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class CacheStatusResponseDto
|
||||||
|
{
|
||||||
|
public DateTime? LastEmployeeRfidCacheSyncUtc { get; set; }
|
||||||
|
public DateTime? LastMealMenuCacheSyncUtc { get; set; }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,516 @@
|
||||||
|
using System.IO;
|
||||||
|
using System.Net;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using UtopiaCanteen.Shared;
|
||||||
|
using UtopiaCanteenSystem.Data;
|
||||||
|
using UtopiaCanteenSystem.Models;
|
||||||
|
using UtopiaCanteenSystem.Services;
|
||||||
|
|
||||||
|
namespace UtopiaCanteenSystem.Api;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Central server HTTP API (backend). Scanner and settings UIs call this; only this layer touches SQLite and HRMS.
|
||||||
|
/// </summary>
|
||||||
|
public static class CanteenBackendHost
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
PropertyNameCaseInsensitive = true
|
||||||
|
};
|
||||||
|
|
||||||
|
public static async Task RunAsync(CanteenBackendServices backend, 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 backend API listening: {string.Join(", ", prefixes)}"), "CanteenBackendHost");
|
||||||
|
|
||||||
|
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, backend), cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
listener.Stop();
|
||||||
|
listener.Close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task ProcessRequestAsync(HttpListenerContext ctx, CanteenBackendServices backend)
|
||||||
|
{
|
||||||
|
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";
|
||||||
|
var rfid = backend.Rfid;
|
||||||
|
|
||||||
|
if (method == "GET" && path.Equals("/api/health", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
await WriteJsonAsync(ctx, new HealthResponse
|
||||||
|
{
|
||||||
|
Status = "ok",
|
||||||
|
Mode = "server",
|
||||||
|
Utc = DateTime.UtcNow
|
||||||
|
}).ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method == "POST" && path.Equals("/api/rfid/scan", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
await HandleScanAsync(ctx, rfid).ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method == "POST" && path.Equals("/api/cache/sync-now", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
await HandleCacheSyncAsync(ctx, backend).ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method == "POST" && path.Equals("/api/orders/sync-now", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
await HandleOrdersSyncAsync(ctx, backend).ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path.StartsWith("/api/meal-schedules", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
await HandleMealSchedulesAsync(ctx, backend, path, method).ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method == "GET")
|
||||||
|
{
|
||||||
|
if (path.Equals("/api/cache/status", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
await WriteJsonAsync(ctx, new CacheStatusResponse
|
||||||
|
{
|
||||||
|
LastEmployeeRfidCacheSyncUtc = backend.Config.GetLastEmployeeRfidCacheSyncUtc(),
|
||||||
|
LastMealMenuCacheSyncUtc = backend.Config.GetLastMealMenuCacheSyncUtc()
|
||||||
|
}).ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path.Equals("/api/rfid/scans/today", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
await WriteJsonAsync(ctx, rfid.GetScansForToday().Select(ApiDtoMapper.ToRecentScanDto).ToList()).ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path.Equals("/api/rfid/scans/last", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var last = rfid.GetLastScan();
|
||||||
|
await WriteJsonAsync(ctx, last == null ? null : ApiDtoMapper.ToRecentScanDto(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(ApiDtoMapper.ToRecentScanDto).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 StatsDto { 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 StatsDto { 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 StatsDto { 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 StatsDto { Count = n }).ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path.Equals("/api/employees/location-site", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
await HandleEmployeeLocationSiteAsync(ctx).ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await WriteJsonAsync(ctx, new { message = "Not found" }, 404).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.Log(ex, "CanteenBackendHost.ProcessRequestAsync");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await WriteJsonAsync(ctx, new { message = ex.Message }, 500).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task HandleCacheSyncAsync(HttpListenerContext ctx, CanteenBackendServices backend)
|
||||||
|
{
|
||||||
|
var started = DateTime.UtcNow;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await backend.RunCacheSyncExclusiveAsync(async () =>
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(backend.Config.GetHrmsLookupConnectionString()))
|
||||||
|
{
|
||||||
|
return ApiDtoMapper.ToManualSync(false, "MySQL connection string is not configured.", started, DateTime.UtcNow);
|
||||||
|
}
|
||||||
|
|
||||||
|
var sync = await backend.OfflineCacheSync.SyncEmployeeAndMenuCacheAsync().ConfigureAwait(false);
|
||||||
|
var completed = DateTime.UtcNow;
|
||||||
|
if (!sync.Success)
|
||||||
|
{
|
||||||
|
return ApiDtoMapper.ToManualSync(false, sync.ErrorMessage ?? "Cache sync failed.", started, completed, sync.ErrorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
var emp = sync.EmployeeSync?.UpsertedCount ?? 0;
|
||||||
|
var meal = sync.MealMenuSync;
|
||||||
|
var details =
|
||||||
|
$"Employee RFID tags: {emp}; " +
|
||||||
|
$"Meal schedules: {meal?.MealScheduleCount ?? 0}; " +
|
||||||
|
$"Menu weeks: {meal?.LunchMenuWeekCount ?? 0}; " +
|
||||||
|
$"Menu items: {meal?.LunchMenuItemCount ?? 0}; " +
|
||||||
|
$"Menu catalog: {meal?.MenuItemCount ?? 0}.";
|
||||||
|
|
||||||
|
return ApiDtoMapper.ToManualSync(true, "Employee and menu cache synced successfully.", started, completed, details);
|
||||||
|
}).ConfigureAwait(false);
|
||||||
|
|
||||||
|
await WriteJsonAsync(ctx, result, result.Success ? 200 : 500).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.Log(ex, "CanteenBackendHost.HandleCacheSyncAsync");
|
||||||
|
await WriteJsonAsync(ctx, ApiDtoMapper.ToManualSync(false, ex.Message, started, DateTime.UtcNow, ex.ToString()), 500).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task HandleOrdersSyncAsync(HttpListenerContext ctx, CanteenBackendServices backend)
|
||||||
|
{
|
||||||
|
var started = DateTime.UtcNow;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await backend.RunOrderSyncExclusiveAsync(async () =>
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(backend.Config.GetMySqlConnectionString()))
|
||||||
|
{
|
||||||
|
return ApiDtoMapper.ToManualSync(false, "Production MySQL connection is not configured.", started, DateTime.UtcNow);
|
||||||
|
}
|
||||||
|
|
||||||
|
await backend.ProductionSync.SyncNowAsync().ConfigureAwait(false);
|
||||||
|
var completed = DateTime.UtcNow;
|
||||||
|
return ApiDtoMapper.ToManualSync(
|
||||||
|
true,
|
||||||
|
"Pending orders posted to production.",
|
||||||
|
started,
|
||||||
|
completed,
|
||||||
|
"Unsynced lunch_order_transactions were posted and marked IsSynced where successful.");
|
||||||
|
}).ConfigureAwait(false);
|
||||||
|
|
||||||
|
await WriteJsonAsync(ctx, result, result.Success ? 200 : 500).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.Log(ex, "CanteenBackendHost.HandleOrdersSyncAsync");
|
||||||
|
await WriteJsonAsync(ctx, ApiDtoMapper.ToManualSync(false, ex.Message, started, DateTime.UtcNow, ex.ToString()), 500).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task HandleScanAsync(HttpListenerContext ctx, RfidService rfid)
|
||||||
|
{
|
||||||
|
ScanRequest? 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<ScanRequest>(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 = ApiDtoMapper.ToScanResponse(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, PUT, DELETE, 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 async Task HandleEmployeeLocationSiteAsync(HttpListenerContext ctx)
|
||||||
|
{
|
||||||
|
var employeeId = ParseQueryString(ctx.Request.Url?.Query, "employeeId")?.Trim() ?? string.Empty;
|
||||||
|
if (string.IsNullOrEmpty(employeeId))
|
||||||
|
{
|
||||||
|
await WriteJsonAsync(ctx, new EmployeeLocationSiteResponse(), 400).ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var dbFactory = new DbContextFactory();
|
||||||
|
await using var db = dbFactory.CreateDbContext();
|
||||||
|
var siteId = await db.EmployeeRfidTagCache
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(x => x.ParentDocumentType == "Employee")
|
||||||
|
.Where(x => x.EmployeeSerialNumber == employeeId)
|
||||||
|
.Where(x => !string.IsNullOrEmpty(x.LocationSiteId))
|
||||||
|
.Select(x => x.LocationSiteId)
|
||||||
|
.FirstOrDefaultAsync()
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
await WriteJsonAsync(ctx, new EmployeeLocationSiteResponse
|
||||||
|
{
|
||||||
|
LocationSiteId = string.IsNullOrWhiteSpace(siteId) ? null : siteId.Trim()
|
||||||
|
}).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.Log(ex, "CanteenBackendHost.HandleEmployeeLocationSiteAsync");
|
||||||
|
await WriteJsonAsync(ctx, new EmployeeLocationSiteResponse(), 500).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task HandleMealSchedulesAsync(HttpListenerContext ctx, CanteenBackendServices backend, string path, string method)
|
||||||
|
{
|
||||||
|
var mealSvc = new ProductionMealScheduleService(backend.Config);
|
||||||
|
|
||||||
|
if (method == "GET" && path.Equals("/api/meal-schedules", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var list = await mealSvc.GetAllSchedulesAsync().ConfigureAwait(false);
|
||||||
|
await WriteJsonAsync(ctx, list.Select(ApiDtoMapper.ToMealScheduleDto).ToList()).ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method == "POST" && path.Equals("/api/meal-schedules", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var dto = await ReadJsonBodyAsync<MealScheduleDto>(ctx).ConfigureAwait(false);
|
||||||
|
if (dto == null)
|
||||||
|
{
|
||||||
|
await WriteJsonAsync(ctx, new { message = "Request body required." }, 400).ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var entity = ApiDtoMapper.ToMealSchedule(dto);
|
||||||
|
var id = await mealSvc.CreateAsync(entity).ConfigureAwait(false);
|
||||||
|
entity.Id = id;
|
||||||
|
await WriteJsonAsync(ctx, ApiDtoMapper.ToMealScheduleDto(entity), 201).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.Log(ex, "CanteenBackendHost.CreateMealSchedule");
|
||||||
|
await WriteJsonAsync(ctx, new { message = ex.Message }, 500).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var scheduleId = TryParseMealScheduleId(path);
|
||||||
|
if (scheduleId.HasValue)
|
||||||
|
{
|
||||||
|
if (method == "PUT")
|
||||||
|
{
|
||||||
|
var dto = await ReadJsonBodyAsync<MealScheduleDto>(ctx).ConfigureAwait(false);
|
||||||
|
if (dto == null)
|
||||||
|
{
|
||||||
|
await WriteJsonAsync(ctx, new { message = "Request body required." }, 400).ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var entity = ApiDtoMapper.ToMealSchedule(dto);
|
||||||
|
entity.Id = scheduleId.Value;
|
||||||
|
await mealSvc.UpdateAsync(entity).ConfigureAwait(false);
|
||||||
|
await WriteJsonAsync(ctx, ApiDtoMapper.ToMealScheduleDto(entity)).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.Log(ex, "CanteenBackendHost.UpdateMealSchedule");
|
||||||
|
await WriteJsonAsync(ctx, new { message = ex.Message }, 500).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method == "DELETE")
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await mealSvc.DeleteAsync(scheduleId.Value).ConfigureAwait(false);
|
||||||
|
await WriteJsonAsync(ctx, new { success = true }).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.Log(ex, "CanteenBackendHost.DeleteMealSchedule");
|
||||||
|
await WriteJsonAsync(ctx, new { message = ex.Message }, 500).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await WriteJsonAsync(ctx, new { message = "Not found" }, 404).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long? TryParseMealScheduleId(string path)
|
||||||
|
{
|
||||||
|
const string prefix = "/api/meal-schedules/";
|
||||||
|
if (!path.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||||
|
return null;
|
||||||
|
var idPart = path[prefix.Length..];
|
||||||
|
return long.TryParse(idPart, out var id) ? id : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<T?> ReadJsonBodyAsync<T>(HttpListenerContext ctx) where T : class
|
||||||
|
{
|
||||||
|
using var reader = new StreamReader(ctx.Request.InputStream, ctx.Request.ContentEncoding);
|
||||||
|
var body = await reader.ReadToEndAsync().ConfigureAwait(false);
|
||||||
|
if (string.IsNullOrWhiteSpace(body))
|
||||||
|
return null;
|
||||||
|
return JsonSerializer.Deserialize<T>(body, JsonOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,263 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
namespace UtopiaCanteenSystem.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Server-side backend dependencies: SQLite, RFID processing, HRMS cache sync, production post.
|
||||||
|
/// Only constructed when <see cref="Models.AppMode.Server"/>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CanteenBackendServices
|
||||||
|
{
|
||||||
|
private readonly SemaphoreSlim _cacheSyncLock = new(1, 1);
|
||||||
|
private readonly SemaphoreSlim _orderSyncLock = new(1, 1);
|
||||||
|
|
||||||
|
public CanteenBackendServices(
|
||||||
|
RfidService rfid,
|
||||||
|
IOfflineCacheSyncService offlineCacheSync,
|
||||||
|
ISyncService productionSync,
|
||||||
|
IConfigService config)
|
||||||
|
{
|
||||||
|
Rfid = rfid;
|
||||||
|
OfflineCacheSync = offlineCacheSync;
|
||||||
|
ProductionSync = productionSync;
|
||||||
|
Config = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
public RfidService Rfid { get; }
|
||||||
|
public IOfflineCacheSyncService OfflineCacheSync { get; }
|
||||||
|
public ISyncService ProductionSync { get; }
|
||||||
|
public IConfigService Config { get; }
|
||||||
|
|
||||||
|
public async Task<T> RunCacheSyncExclusiveAsync<T>(Func<Task<T>> action, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await _cacheSyncLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await action().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_cacheSyncLock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<T> RunOrderSyncExclusiveAsync<T>(Func<Task<T>> action, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await _orderSyncLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await action().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_orderSyncLock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue