Add backend Windows service project

Adds a Windows worker service host for running the canteen backend independently, including backend configuration and worker startup logic.
feature/centralized-offline-canteen
SYED MUSTUFA AHMED NAQVI 2026-05-25 16:25:35 +05:00
parent e59a3f8af6
commit eff36f0e9f
6 changed files with 511 additions and 0 deletions

View File

@ -0,0 +1,48 @@
using UtopiaCanteen.Shared;
using UtopiaCanteenSystem.Api;
using UtopiaCanteenSystem.Models;
namespace UtopiaCanteenSystem.Services;
/// <summary>
/// Meal schedule CRUD via central backend API (client never touches HRMS/MySQL).
/// </summary>
public sealed class BackendApiMealScheduleService : IMealScheduleService
{
private readonly ICanteenBackendApiClient _api;
public BackendApiMealScheduleService(ICanteenBackendApiClient api)
{
_api = api;
}
public IReadOnlyList<MealSchedule> GetActiveSchedulesForSite(string siteId)
{
var all = GetAllSchedulesAsync().GetAwaiter().GetResult();
if (string.IsNullOrWhiteSpace(siteId))
return all;
var site = siteId.Trim().Replace("SITE : ", "", StringComparison.OrdinalIgnoreCase).Trim();
return all.Where(s => string.Equals(s.LocationSiteId?.Trim(), site, StringComparison.OrdinalIgnoreCase)).ToList();
}
public async Task<IReadOnlyList<MealSchedule>> GetAllSchedulesAsync(CancellationToken cancellationToken = default)
{
var dtos = await _api.GetMealSchedulesAsync(cancellationToken).ConfigureAwait(false);
return dtos.Select(ApiDtoMapper.ToMealSchedule).ToList();
}
public async Task<long> CreateAsync(MealSchedule schedule, CancellationToken cancellationToken = default)
{
var dto = ApiDtoMapper.ToMealScheduleDto(schedule);
return await _api.CreateMealScheduleAsync(dto, cancellationToken).ConfigureAwait(false);
}
public async Task UpdateAsync(MealSchedule schedule, CancellationToken cancellationToken = default)
{
var dto = ApiDtoMapper.ToMealScheduleDto(schedule);
await _api.UpdateMealScheduleAsync(dto, cancellationToken).ConfigureAwait(false);
}
public async Task DeleteAsync(long id, CancellationToken cancellationToken = default) =>
await _api.DeleteMealScheduleAsync(id, cancellationToken).ConfigureAwait(false);
}

View File

@ -0,0 +1,48 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>UtopiaCanteenSystem</RootNamespace>
<AssemblyName>UtopiaCanteen.Backend</AssemblyName>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\UtopiaCanteen.Shared\UtopiaCanteen.Shared.csproj" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.11" />
<PackageReference Include="MySqlConnector" Version="2.3.5" />
</ItemGroup>
<ItemGroup>
<Compile Include="..\Data\**\*.cs" LinkBase="Data" />
<Compile Include="..\Models\**\*.cs" LinkBase="Models" />
<Compile Include="..\Helpers\**\*.cs" LinkBase="Helpers" />
<Compile Include="..\Api\CanteenBackendHost.cs" Link="Api\CanteenBackendHost.cs" />
<Compile Include="..\Api\ApiDtoMapper.cs" Link="Api\ApiDtoMapper.cs" />
<Compile Include="..\Services\**\*.cs" LinkBase="Services" />
<Compile Remove="..\Services\CanteenBackendApiClient.cs" />
<Compile Remove="..\Services\ICanteenBackendApiClient.cs" />
<Compile Remove="..\Services\EmptyMenuLookupService.cs" />
<Compile Remove="..\Services\AuthService.cs" />
<Compile Remove="..\Services\IAuthService.cs" />
<Compile Remove="..\Services\AuthResult.cs" />
<Compile Remove="..\Services\NavigationService.cs" />
<Compile Remove="..\Services\INavigationService.cs" />
<Compile Remove="..\Services\AppSession.cs" />
<Compile Remove="..\Services\AdminAuditService.cs" />
<Compile Remove="..\Services\IAdminAuditService.cs" />
<Compile Remove="..\Services\EmployeePhotoService.cs" />
<Compile Remove="..\Services\IEmployeePhotoService.cs" />
<Compile Remove="..\Services\SampleSyncApiController.cs" />
<Compile Remove="..\Services\ClientConfigService.cs" />
<Compile Remove="..\Services\NoOpEmployeePhotoService.cs" />
<Compile Remove="..\Services\NoOpMealScheduleService.cs" />
<Compile Remove="..\Models\OrderHistoryItem.cs" />
<Compile Remove="..\Services\ConfigService.cs" />
<Compile Remove="..\Services\BackendApiMealScheduleService.cs" />
<Compile Remove="..\Services\AdminLoginHelper.cs" />
<Compile Remove="..\Api\RfidApiDtos.cs" />
<Compile Remove="..\Api\BackendApiDtos.cs" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,188 @@
using System.Text.Json;
using Microsoft.Extensions.Configuration;
using UtopiaCanteenSystem.Data;
using UtopiaCanteenSystem.Models;
using UtopiaCanteenSystem.Services;
namespace UtopiaCanteen.BackendService;
/// <summary>
/// Backend service configuration: appsettings.json + persisted JSON under LocalApplicationData.
/// </summary>
public sealed class BackendHostConfigService : IConfigService
{
private readonly IConfiguration _configuration;
private readonly string _persistedPath;
private PersistedConfig _persisted;
public BackendHostConfigService(IConfiguration configuration)
{
_configuration = configuration;
_persistedPath = Path.Combine(DatabasePath.GetAppDataFolder(), "backend-settings.json");
_persisted = LoadPersisted();
MergeFromAppSettingsIfNeeded();
}
public string GetListenUrls() =>
_configuration["ListenUrls"] ?? "http://0.0.0.0:5000";
public string GetSyncApiEndpoint() => _configuration["SyncApiEndpoint"] ?? _persisted.SyncApiEndpoint ?? string.Empty;
public void SetSyncApiEndpoint(string endpoint) { _persisted.SyncApiEndpoint = endpoint ?? string.Empty; SavePersisted(); }
public bool GetSyncServiceEnabled() =>
_configuration.GetValue("SyncServiceEnabled", _persisted.SyncServiceEnabled);
public void SetSyncServiceEnabled(bool enabled) { _persisted.SyncServiceEnabled = enabled; SavePersisted(); }
public bool GetScannerConnected() => false;
public void SetScannerConnected(bool connected) { }
public int GetScanIntervalDays() => 0;
public void SetScanIntervalDays(int value) { }
public int GetScanIntervalHours() => 0;
public void SetScanIntervalHours(int value) { }
public int GetScanIntervalMinutes() => 1;
public void SetScanIntervalMinutes(int value) { }
public int GetScanIntervalSeconds() => 0;
public void SetScanIntervalSeconds(int value) { }
public TimeSpan GetScanInterval() => TimeSpan.FromMinutes(1);
public string GetAdminCardId() => "ADMIN";
public void SetAdminCardId(string cardId) { }
public string GetSiteId() => _persisted.SiteId ?? "02";
public void SetSiteId(string siteId) { _persisted.SiteId = siteId ?? string.Empty; SavePersisted(); }
public void ApplyLocationSiteIdFromAuth(string? locationSiteId) { }
public string GetDeviceId() => _persisted.DeviceId ?? string.Empty;
public void SetDeviceId(string deviceId) { _persisted.DeviceId = deviceId ?? string.Empty; SavePersisted(); }
public bool GetRememberAdminCredentials() => false;
public void SetRememberAdminCredentials(bool remember) { }
public string GetSavedAdminUsername() => string.Empty;
public void SetSavedAdminUsername(string username) { }
public string GetSavedAdminPassword() => string.Empty;
public void SetSavedAdminPassword(string password) { }
public string GetMySqlConnectionString() =>
FirstNonEmpty(_configuration["MySqlConnectionString"], _persisted.MySqlConnectionString);
public void SetMySqlConnectionString(string connectionString)
{
_persisted.MySqlConnectionString = connectionString ?? string.Empty;
SavePersisted();
}
public string GetHrmsLookupConnectionString() =>
ConfigConnectionHelper.GetHrmsOrProductionConnectionString(
FirstNonEmpty(_configuration["HrmsLookupConnectionString"], _persisted.HrmsLookupConnectionString),
GetMySqlConnectionString());
public void SetHrmsLookupConnectionString(string connectionString)
{
_persisted.HrmsLookupConnectionString = connectionString ?? string.Empty;
SavePersisted();
}
public DateTime? GetLastEmployeeRfidCacheSyncUtc() => _persisted.LastEmployeeRfidCacheSyncUtc;
public void SetLastEmployeeRfidCacheSyncUtc(DateTime utc)
{
_persisted.LastEmployeeRfidCacheSyncUtc = utc;
SavePersisted();
}
public DateTime? GetLastMealMenuCacheSyncUtc() => _persisted.LastMealMenuCacheSyncUtc;
public void SetLastMealMenuCacheSyncUtc(DateTime utc)
{
_persisted.LastMealMenuCacheSyncUtc = utc;
SavePersisted();
}
public AppMode GetAppMode() => AppMode.Server;
public void SetAppMode(AppMode mode) { }
public string GetCentralServerBaseUrl() => string.Empty;
public void SetCentralServerBaseUrl(string url) { }
public string GetBackendBaseUrl() => DeriveLocalhostApiBaseUrl(GetListenUrls());
public string GetLocalServerListenUrls() => GetListenUrls();
public void SetLocalServerListenUrls(string urls) { }
private void MergeFromAppSettingsIfNeeded()
{
var mysql = _configuration["MySqlConnectionString"];
if (!string.IsNullOrWhiteSpace(mysql) && string.IsNullOrWhiteSpace(_persisted.MySqlConnectionString))
_persisted.MySqlConnectionString = mysql;
var hrms = _configuration["HrmsLookupConnectionString"];
if (!string.IsNullOrWhiteSpace(hrms) && string.IsNullOrWhiteSpace(_persisted.HrmsLookupConnectionString))
_persisted.HrmsLookupConnectionString = hrms;
SavePersisted();
}
private static string FirstNonEmpty(params string?[] values)
{
foreach (var v in values)
{
if (!string.IsNullOrWhiteSpace(v))
return v!;
}
return string.Empty;
}
private static string DeriveLocalhostApiBaseUrl(string listenUrls)
{
var first = listenUrls
.Split(new[] { ';', ',' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.FirstOrDefault() ?? "http://0.0.0.0:5000";
first = first.TrimEnd('/');
if (first.Contains("0.0.0.0", StringComparison.Ordinal))
first = first.Replace("0.0.0.0", "localhost", StringComparison.Ordinal);
if (first.Contains('+'))
first = first.Replace("+", "localhost", StringComparison.Ordinal);
return first;
}
private PersistedConfig LoadPersisted()
{
try
{
if (!File.Exists(_persistedPath))
return new PersistedConfig();
var json = File.ReadAllText(_persistedPath);
return JsonSerializer.Deserialize<PersistedConfig>(json) ?? new PersistedConfig();
}
catch
{
return new PersistedConfig();
}
}
private void SavePersisted()
{
try
{
var json = JsonSerializer.Serialize(_persisted, new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(_persistedPath, json);
}
catch (Exception ex)
{
Logger.Log(ex, "BackendHostConfigService.SavePersisted");
}
}
private sealed class PersistedConfig
{
public string? SyncApiEndpoint { get; set; }
public bool SyncServiceEnabled { get; set; } = true;
public string? MySqlConnectionString { get; set; }
public string? HrmsLookupConnectionString { get; set; }
public DateTime? LastEmployeeRfidCacheSyncUtc { get; set; }
public DateTime? LastMealMenuCacheSyncUtc { get; set; }
public string? SiteId { get; set; }
public string? DeviceId { get; set; }
}
}

View File

@ -0,0 +1,187 @@
using Microsoft.EntityFrameworkCore;
using UtopiaCanteenSystem.Api;
using UtopiaCanteenSystem.Data;
using UtopiaCanteenSystem.Services;
namespace UtopiaCanteen.BackendService;
public sealed class CanteenBackendWorker : BackgroundService
{
private readonly IConfigService _config;
private readonly DbContextFactory _dbFactory;
private readonly IHostApplicationLifetime _lifetime;
private readonly IConfiguration _configuration;
private CancellationTokenSource? _apiCts;
private Task? _apiTask;
private System.Timers.Timer? _cacheSyncTimer;
private System.Timers.Timer? _orderSyncTimer;
private int _cacheSyncRunning;
private int _orderSyncRunning;
private CanteenBackendServices? _backend;
public CanteenBackendWorker(
IConfigService config,
DbContextFactory dbFactory,
IHostApplicationLifetime lifetime,
IConfiguration configuration)
{
_config = config;
_dbFactory = dbFactory;
_lifetime = lifetime;
_configuration = configuration;
}
public override async Task StartAsync(CancellationToken cancellationToken)
{
Logger.Log(new Exception("UtopiaCanteen backend service starting."), "CanteenBackendWorker");
using (var db = _dbFactory.CreateDbContext())
db.EnsureDatabaseCreated();
var employeeRfidTagSync = new EmployeeRfidTagSyncService(_dbFactory, _config);
var mealMenuCacheSync = new MealMenuCacheSyncService(_dbFactory, _config);
var offlineCacheSync = new OfflineCacheSyncService(employeeRfidTagSync, mealMenuCacheSync);
var employeeLookup = new EmployeeLookupService(_dbFactory, _config);
var menuLookup = new MenuLookupService(_dbFactory);
var mealSessionResolver = new DbMealSessionResolver(_dbFactory);
var productionSync = new SyncService(_dbFactory, _config);
var rfid = new RfidService(_dbFactory, _config, employeeLookup, mealSessionResolver, menuLookup, productionSync);
_backend = new CanteenBackendServices(rfid, offlineCacheSync, productionSync, _config);
_apiCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
var listenUrls = _config.GetLocalServerListenUrls();
var backend = _backend;
var cts = _apiCts;
_apiTask = Task.Run(async () =>
{
try
{
await Task.Delay(500, cts.Token).ConfigureAwait(false);
await CanteenBackendHost.RunAsync(backend, listenUrls, cts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// Shutdown
}
catch (Exception ex)
{
Logger.Log(ex, "CanteenBackendWorker.ApiHost");
}
}, CancellationToken.None);
_ = RunStartupCacheSyncAsync(cancellationToken);
var cacheMinutes = Math.Max(1, _configuration.GetValue("CacheSyncIntervalMinutes", 15));
if (!string.IsNullOrWhiteSpace(_config.GetHrmsLookupConnectionString()))
{
_cacheSyncTimer = new System.Timers.Timer(TimeSpan.FromMinutes(cacheMinutes).TotalMilliseconds) { AutoReset = true };
_cacheSyncTimer.Elapsed += async (_, _) => await RunCacheSyncSafeAsync().ConfigureAwait(false);
_cacheSyncTimer.Start();
}
var orderMinutes = Math.Max(1, _configuration.GetValue("ProductionSyncIntervalMinutes", 1));
if (_config.GetSyncServiceEnabled() && !string.IsNullOrWhiteSpace(_config.GetMySqlConnectionString()))
{
_orderSyncTimer = new System.Timers.Timer(TimeSpan.FromMinutes(orderMinutes).TotalMilliseconds) { AutoReset = true };
_orderSyncTimer.Elapsed += async (_, _) => await RunOrderSyncSafeAsync().ConfigureAwait(false);
_orderSyncTimer.Start();
}
await base.StartAsync(cancellationToken).ConfigureAwait(false);
}
public override async Task StopAsync(CancellationToken cancellationToken)
{
Logger.Log(new Exception("UtopiaCanteen backend service stopping."), "CanteenBackendWorker");
_cacheSyncTimer?.Stop();
_orderSyncTimer?.Stop();
_apiCts?.Cancel();
if (_apiTask != null)
{
try
{
await _apiTask.WaitAsync(TimeSpan.FromSeconds(10), cancellationToken).ConfigureAwait(false);
}
catch
{
// Best-effort
}
}
_cacheSyncTimer?.Dispose();
_orderSyncTimer?.Dispose();
_apiCts?.Dispose();
await base.StopAsync(cancellationToken).ConfigureAwait(false);
}
protected override Task ExecuteAsync(CancellationToken stoppingToken) =>
Task.Delay(Timeout.Infinite, stoppingToken);
private async Task RunStartupCacheSyncAsync(CancellationToken cancellationToken)
{
if (_backend == null || string.IsNullOrWhiteSpace(_config.GetHrmsLookupConnectionString()))
return;
for (var i = 0; i < 30 && !cancellationToken.IsCancellationRequested; i++)
{
try
{
await _backend.RunCacheSyncExclusiveAsync(async () =>
{
await _backend.OfflineCacheSync.SyncEmployeeAndMenuCacheAsync().ConfigureAwait(false);
return true;
}).ConfigureAwait(false);
return;
}
catch (Exception ex)
{
Logger.Log(ex, "CanteenBackendWorker.RunStartupCacheSyncAsync");
}
await Task.Delay(1000, cancellationToken).ConfigureAwait(false);
}
}
private async Task RunCacheSyncSafeAsync()
{
if (_backend == null || Interlocked.Exchange(ref _cacheSyncRunning, 1) == 1)
return;
try
{
await _backend.RunCacheSyncExclusiveAsync(async () =>
{
await _backend.OfflineCacheSync.SyncEmployeeAndMenuCacheAsync().ConfigureAwait(false);
return true;
}).ConfigureAwait(false);
}
catch (Exception ex)
{
Logger.Log(ex, "CanteenBackendWorker.RunCacheSyncSafeAsync");
}
finally
{
Interlocked.Exchange(ref _cacheSyncRunning, 0);
}
}
private async Task RunOrderSyncSafeAsync()
{
if (_backend == null || Interlocked.Exchange(ref _orderSyncRunning, 1) == 1)
return;
try
{
await _backend.RunOrderSyncExclusiveAsync(async () =>
{
await _backend.ProductionSync.SyncNowAsync().ConfigureAwait(false);
return true;
}).ConfigureAwait(false);
}
catch (Exception ex)
{
Logger.Log(ex, "CanteenBackendWorker.RunOrderSyncSafeAsync");
}
finally
{
Interlocked.Exchange(ref _orderSyncRunning, 0);
}
}
}

View File

@ -0,0 +1,19 @@
using Microsoft.EntityFrameworkCore;
using UtopiaCanteen.BackendService;
using UtopiaCanteenSystem.Data;
using UtopiaCanteenSystem.Services;
DatabasePath.UseBackendServiceStorage();
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddWindowsService(options =>
{
options.ServiceName = "UtopiaCanteenBackend";
});
builder.Services.AddSingleton<IConfigService, BackendHostConfigService>();
builder.Services.AddSingleton<DbContextFactory>();
builder.Services.AddHostedService<CanteenBackendWorker>();
var host = builder.Build();
await host.RunAsync();

View File

@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk.Worker">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>UtopiaCanteen.BackendService</RootNamespace>
<AssemblyName>UtopiaCanteen.BackendService</AssemblyName>
<OutputType>Exe</OutputType>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\UtopiaCanteen.Backend\UtopiaCanteen.Backend.csproj" />
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="8.0.1" />
</ItemGroup>
<ItemGroup>
<None Update="appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>