148 lines
6.2 KiB
C#
148 lines
6.2 KiB
C#
using Microsoft.EntityFrameworkCore;
|
|
using MySqlConnector;
|
|
using UtopiaCanteenSystem.Data;
|
|
using UtopiaCanteenSystem.Models;
|
|
|
|
namespace UtopiaCanteenSystem.Services;
|
|
|
|
/// <summary>
|
|
/// Scheduled sync: fetches unsynced lunch_order_transactions, inserts them into MySQL database,
|
|
/// marks them as synced in SQLite, and periodically cleans up old synced rows.
|
|
/// </summary>
|
|
public class SyncService : ISyncService
|
|
{
|
|
private readonly IDbContextFactory<AppDbContext> _dbFactory;
|
|
private readonly IConfigService _configService;
|
|
|
|
public SyncService(IDbContextFactory<AppDbContext> dbFactory, IConfigService configService)
|
|
{
|
|
_dbFactory = dbFactory;
|
|
_configService = configService;
|
|
}
|
|
|
|
public async Task SyncNowAsync(CancellationToken cancellationToken = default)
|
|
{
|
|
var connectionString = _configService.GetMySqlConnectionString();
|
|
// TODO: For local testing only. Set MySqlConnectionString in appsettings.json or via config to avoid hardcoding.
|
|
if (string.IsNullOrWhiteSpace(connectionString))
|
|
connectionString = "Server=localhost;Database=canteen_prod;User=root;Password=Root@12345_;Port=3306;";
|
|
|
|
List<ScanRecord> toSync;
|
|
using (var db = _dbFactory.CreateDbContext())
|
|
{
|
|
toSync = await db.LunchOrderTransactions
|
|
.Where(r => !r.IsSynced)
|
|
.OrderBy(r => r.ScanTime)
|
|
.ToListAsync(cancellationToken)
|
|
.ConfigureAwait(false);
|
|
}
|
|
|
|
if (toSync.Count == 0)
|
|
return;
|
|
|
|
try
|
|
{
|
|
// Insert into MySQL using transaction for atomicity
|
|
await using var mysqlConn = new MySqlConnection(connectionString);
|
|
await mysqlConn.OpenAsync(cancellationToken).ConfigureAwait(false);
|
|
|
|
await using var transaction = await mysqlConn.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
|
|
|
|
try
|
|
{
|
|
// Use INSERT IGNORE to handle duplicates (unique key on DeviceId, DeviceLocalRowId)
|
|
// This ensures no duplicates even if sync runs multiple times
|
|
var insertSql = @"
|
|
INSERT IGNORE INTO lunch_order_transactions
|
|
(DeviceLocalRowId, ScanTimeUtc, SiteId, DeviceId, CardId, IpAddress, ReceivedAtUtc)
|
|
VALUES
|
|
(@DeviceLocalRowId, @ScanTimeUtc, @SiteId, @DeviceId, @CardId, @IpAddress, UTC_TIMESTAMP(3))";
|
|
|
|
await using var cmd = new MySqlCommand(insertSql, mysqlConn, transaction);
|
|
|
|
foreach (var record in toSync)
|
|
{
|
|
cmd.Parameters.Clear();
|
|
cmd.Parameters.AddWithValue("@DeviceLocalRowId", record.Id);
|
|
cmd.Parameters.AddWithValue("@ScanTimeUtc", record.ScanTime);
|
|
cmd.Parameters.AddWithValue("@SiteId", record.SiteId ?? string.Empty);
|
|
cmd.Parameters.AddWithValue("@DeviceId", record.DeviceId ?? string.Empty);
|
|
cmd.Parameters.AddWithValue("@CardId", record.CardId ?? string.Empty);
|
|
cmd.Parameters.AddWithValue("@IpAddress", record.IpAddress ?? string.Empty);
|
|
|
|
var rowsAffected = await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
|
// Note: INSERT IGNORE returns 0 if row already exists (duplicate), 1 if inserted
|
|
}
|
|
|
|
// Commit MySQL transaction
|
|
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
|
|
|
// Only mark as synced in SQLite after successful MySQL insert
|
|
var ids = toSync.Select(r => r.Id).ToList();
|
|
using (var db = _dbFactory.CreateDbContext())
|
|
{
|
|
var records = await db.LunchOrderTransactions
|
|
.Where(r => ids.Contains(r.Id))
|
|
.ToListAsync(cancellationToken)
|
|
.ConfigureAwait(false);
|
|
foreach (var record in records)
|
|
record.IsSynced = true;
|
|
|
|
await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
// Day-end cleanup: delete only synced rows from previous days.
|
|
await CleanupOldSyncedRowsAsync(cancellationToken).ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// Rollback MySQL transaction on error
|
|
try
|
|
{
|
|
await transaction.RollbackAsync(cancellationToken).ConfigureAwait(false);
|
|
}
|
|
catch
|
|
{
|
|
// Ignore rollback errors
|
|
}
|
|
|
|
// Log error for debugging (you can replace with proper logging)
|
|
System.Diagnostics.Debug.WriteLine($"Sync error: {ex.Message}");
|
|
System.Diagnostics.Debug.WriteLine($"Stack trace: {ex.StackTrace}");
|
|
|
|
throw; // Re-throw to be caught by outer catch
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// Log error for debugging (you can replace with proper logging)
|
|
System.Diagnostics.Debug.WriteLine($"Sync failed: {ex.Message}");
|
|
System.Diagnostics.Debug.WriteLine($"Stack trace: {ex.StackTrace}");
|
|
|
|
// Leave records intact; will retry on next run
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Deletes only synced rows (IsSynced = 1) where ScanTime is before the start of today (local day).
|
|
/// </summary>
|
|
private async Task CleanupOldSyncedRowsAsync(CancellationToken cancellationToken)
|
|
{
|
|
// Define "today" by the local calendar day, but ScanTime is stored as UTC.
|
|
var startOfTodayLocal = DateTime.Today;
|
|
var startUtc = startOfTodayLocal.ToUniversalTime();
|
|
|
|
using var db = _dbFactory.CreateDbContext();
|
|
var oldSynced = await db.LunchOrderTransactions
|
|
.Where(r => r.IsSynced && r.ScanTime < startUtc)
|
|
.ToListAsync(cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
if (oldSynced.Count == 0)
|
|
return;
|
|
|
|
db.LunchOrderTransactions.RemoveRange(oldSynced);
|
|
await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
|
}
|
|
}
|