686 lines
30 KiB
C#
686 lines
30 KiB
C#
using Microsoft.Extensions.Configuration;
|
||
using Prism.Events;
|
||
using System.IO;
|
||
using System.Net.Http;
|
||
using System.Text;
|
||
using System.Text.Json;
|
||
using System.Text.Json.Serialization;
|
||
using System.Web;
|
||
using YY.Admin.Core;
|
||
using YY.Admin.Core.Entity;
|
||
using YY.Admin.Core.Events;
|
||
using YY.Admin.Core.Services;
|
||
|
||
namespace YY.Admin.Services.Service.Supplier;
|
||
|
||
public class SupplierService : ISupplierService, ISingletonDependency
|
||
{
|
||
private readonly IHttpClientFactory _httpClientFactory;
|
||
private readonly IConfiguration _configuration;
|
||
private readonly INetworkMonitor _networkMonitor;
|
||
private readonly IEventAggregator _eventAggregator;
|
||
private readonly ILoggerService _logger;
|
||
|
||
private readonly object _cacheLock = new();
|
||
private readonly string _cacheFilePath;
|
||
private readonly string _pendingFilePath;
|
||
private List<MesXslSupplier> _localCache = new();
|
||
|
||
private const int MaxPendingRetries = 5;
|
||
|
||
// 断开期间被本地修改的条目(含版本锚点),重连后推送到后端
|
||
private readonly HashSet<string> _pendingLocalModifiedIds = new(StringComparer.OrdinalIgnoreCase);
|
||
// 断开期间被本地删除的条目
|
||
private readonly HashSet<string> _pendingLocalDeletedIds = new(StringComparer.OrdinalIgnoreCase);
|
||
// 版本锚点:最后一次从后端同步时该条目的 UpdateTime,用于冲突检测
|
||
private readonly Dictionary<string, DateTime?> _anchors = new(StringComparer.OrdinalIgnoreCase);
|
||
// 每条待推送记录的重试次数,超过上限后放弃
|
||
private readonly Dictionary<string, int> _pendingRetryCount = new(StringComparer.OrdinalIgnoreCase);
|
||
|
||
private static readonly JsonSerializerOptions _jsonOpts = new()
|
||
{
|
||
PropertyNameCaseInsensitive = true,
|
||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||
Converters = { new NullableDateTimeJsonConverter() }
|
||
};
|
||
|
||
public SupplierService(
|
||
IHttpClientFactory httpClientFactory,
|
||
IConfiguration configuration,
|
||
INetworkMonitor networkMonitor,
|
||
IEventAggregator eventAggregator,
|
||
ILoggerService logger)
|
||
{
|
||
_httpClientFactory = httpClientFactory;
|
||
_configuration = configuration;
|
||
_networkMonitor = networkMonitor;
|
||
_eventAggregator = eventAggregator;
|
||
_logger = logger;
|
||
var appDataDir = Path.Combine(
|
||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||
"YY.Admin", "sync-cache");
|
||
Directory.CreateDirectory(appDataDir);
|
||
_cacheFilePath = Path.Combine(appDataDir, "mes-xsl-supplier-cache.json");
|
||
_pendingFilePath = Path.Combine(appDataDir, "mes-xsl-supplier-pending.json");
|
||
LoadCacheFromDisk();
|
||
LoadPendingFromDisk();
|
||
}
|
||
|
||
// ── 配置 ──────────────────────────────────────────────────────────────────
|
||
|
||
private string BaseUrl =>
|
||
(_configuration.GetValue<string>("JeecgIntegration:BaseUrl") ?? "http://localhost:8080/jeecg-boot").TrimEnd('/');
|
||
private int DefaultTenantId =>
|
||
(int?)_configuration.GetValue<long?>("JeecgIntegration:DefaultTenantId") ?? 1002;
|
||
private HttpClient CreateClient() => _httpClientFactory.CreateClient("JeecgApi");
|
||
|
||
// ── 公开接口 ──────────────────────────────────────────────────────────────
|
||
|
||
public async Task<SupplierPageResult> PageAsync(
|
||
int pageNo, int pageSize,
|
||
string? supplierCode = null, string? supplierName = null,
|
||
string? supplierShortName = null, string? erpCode = null,
|
||
string? status = null, CancellationToken ct = default)
|
||
{
|
||
List<MesXslSupplier> source;
|
||
try
|
||
{
|
||
source = _networkMonitor.IsOnline
|
||
? await FetchRemoteListAsync(ct).ConfigureAwait(false)
|
||
: GetCacheSnapshot();
|
||
lock (_cacheLock)
|
||
{
|
||
MergeIntoCache(source);
|
||
SaveCacheToDiskUnsafe();
|
||
}
|
||
source = GetCacheSnapshot();
|
||
}
|
||
catch
|
||
{
|
||
source = GetCacheSnapshot();
|
||
}
|
||
|
||
IEnumerable<MesXslSupplier> q = source;
|
||
if (!string.IsNullOrWhiteSpace(supplierCode))
|
||
q = q.Where(x => (x.SupplierCode ?? "").Contains(supplierCode, StringComparison.OrdinalIgnoreCase));
|
||
if (!string.IsNullOrWhiteSpace(supplierName))
|
||
q = q.Where(x => (x.SupplierName ?? "").Contains(supplierName, StringComparison.OrdinalIgnoreCase));
|
||
if (!string.IsNullOrWhiteSpace(supplierShortName))
|
||
q = q.Where(x => (x.SupplierShortName ?? "").Contains(supplierShortName, StringComparison.OrdinalIgnoreCase));
|
||
if (!string.IsNullOrWhiteSpace(erpCode))
|
||
q = q.Where(x => (x.ErpCode ?? "").Contains(erpCode, StringComparison.OrdinalIgnoreCase));
|
||
if (!string.IsNullOrWhiteSpace(status))
|
||
q = q.Where(x => string.Equals(x.Status, status, StringComparison.OrdinalIgnoreCase));
|
||
|
||
var ordered = q.OrderByDescending(x => x.CreateTime ?? DateTime.MinValue).ToList();
|
||
var total = ordered.Count;
|
||
var records = ordered.Skip(Math.Max(0, (pageNo - 1) * pageSize)).Take(pageSize).ToList();
|
||
return new SupplierPageResult(records, total, pageNo, pageSize);
|
||
}
|
||
|
||
public async Task<MesXslSupplier?> GetByIdAsync(string id, CancellationToken ct = default)
|
||
{
|
||
// 有本地待同步改动时优先返回本地版本,避免编辑弹窗被后端旧版本覆盖
|
||
if (_networkMonitor.IsOnline && !_pendingLocalModifiedIds.Contains(id))
|
||
{
|
||
var url = $"{BaseUrl}/xslmes/mesXslSupplier/anon/queryById?id={Uri.EscapeDataString(id)}&tenantId={DefaultTenantId}";
|
||
using var client = CreateClient();
|
||
var resp = await client.GetAsync(url, ct).ConfigureAwait(false);
|
||
if (resp.IsSuccessStatusCode)
|
||
{
|
||
var json = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||
using var doc = JsonDocument.Parse(json);
|
||
if (doc.RootElement.TryGetProperty("result", out var resultEl))
|
||
return resultEl.Deserialize<MesXslSupplier>(_jsonOpts);
|
||
}
|
||
}
|
||
return GetCacheSnapshot().FirstOrDefault(x => string.Equals(x.Id, id, StringComparison.OrdinalIgnoreCase));
|
||
}
|
||
|
||
public Task<bool> AddAsync(MesXslSupplier supplier, CancellationToken ct = default) =>
|
||
PostAndRefreshAsync(
|
||
$"{BaseUrl}/xslmes/mesXslSupplier/anon/add?tenantId={DefaultTenantId}",
|
||
supplier, "add", supplier.Id, ct);
|
||
|
||
public Task<bool> EditAsync(MesXslSupplier supplier, CancellationToken ct = default) =>
|
||
PostAndRefreshAsync(
|
||
$"{BaseUrl}/xslmes/mesXslSupplier/anon/edit?tenantId={DefaultTenantId}",
|
||
supplier, "edit", supplier.Id, ct);
|
||
|
||
public async Task<bool> DeleteAsync(string id, CancellationToken ct = default)
|
||
{
|
||
using var client = CreateClient();
|
||
var url = $"{BaseUrl}/xslmes/mesXslSupplier/anon/delete?id={Uri.EscapeDataString(id)}&tenantId={DefaultTenantId}";
|
||
var resp = await client.DeleteAsync(url, ct).ConfigureAwait(false);
|
||
|
||
if (IsDisconnectedResponse(resp))
|
||
{
|
||
var anchor = GetCacheSnapshot()
|
||
.FirstOrDefault(x => string.Equals(x.Id, id, StringComparison.OrdinalIgnoreCase))?.UpdateTime;
|
||
RemoveFromLocalCache(id);
|
||
MarkLocalDeleted(id, anchor);
|
||
_eventAggregator.GetEvent<SupplierChangedEvent>()
|
||
.Publish(new SupplierChangedPayload { Action = "delete", SupplierId = id });
|
||
return true;
|
||
}
|
||
|
||
var ok = resp.IsSuccessStatusCode && await IsSuccessResultAsync(resp, ct).ConfigureAwait(false);
|
||
if (ok)
|
||
{
|
||
RemoveFromLocalCache(id);
|
||
ClearPending(id);
|
||
_eventAggregator.GetEvent<SupplierChangedEvent>()
|
||
.Publish(new SupplierChangedPayload { Action = "delete", SupplierId = id });
|
||
}
|
||
return ok;
|
||
}
|
||
|
||
public async Task<bool> UpdateStatusAsync(string id, string status, CancellationToken ct = default)
|
||
{
|
||
using var client = CreateClient();
|
||
var url = $"{BaseUrl}/xslmes/mesXslSupplier/anon/updateStatus?id={Uri.EscapeDataString(id)}&status={Uri.EscapeDataString(status)}&tenantId={DefaultTenantId}";
|
||
var resp = await client.PostAsync(url, null, ct).ConfigureAwait(false);
|
||
|
||
if (IsDisconnectedResponse(resp))
|
||
{
|
||
var anchor = GetCacheSnapshot()
|
||
.FirstOrDefault(x => string.Equals(x.Id, id, StringComparison.OrdinalIgnoreCase))?.UpdateTime;
|
||
UpdateLocalStatus(id, status);
|
||
MarkLocalModified(id, anchor);
|
||
_eventAggregator.GetEvent<SupplierChangedEvent>()
|
||
.Publish(new SupplierChangedPayload { Action = "status", SupplierId = id });
|
||
return true;
|
||
}
|
||
|
||
var ok = resp.IsSuccessStatusCode && await IsSuccessResultAsync(resp, ct).ConfigureAwait(false);
|
||
if (ok)
|
||
{
|
||
UpdateLocalStatus(id, status);
|
||
ClearPending(id);
|
||
_eventAggregator.GetEvent<SupplierChangedEvent>()
|
||
.Publish(new SupplierChangedPayload { Action = "status", SupplierId = id });
|
||
}
|
||
return ok;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 重连后将离线期间的本地改动推送到后端,并检测冲突。
|
||
/// 调用方应在此方法完成后再触发 UI 刷新,确保页面看到的是推送结果。
|
||
/// </summary>
|
||
public async Task<PushPendingResult> PushPendingOnReconnectAsync(CancellationToken ct = default)
|
||
{
|
||
int pushed = 0, conflicts = 0, newPushed = 0;
|
||
|
||
// ── 1. 推送离线期间修改的现有记录 ─────────────────────────────────────
|
||
foreach (var id in new List<string>(_pendingLocalModifiedIds))
|
||
{
|
||
try
|
||
{
|
||
var local = GetCacheSnapshot()
|
||
.FirstOrDefault(x => string.Equals(x.Id, id, StringComparison.OrdinalIgnoreCase));
|
||
if (local == null) { ClearPending(id); continue; }
|
||
|
||
var remote = await FetchRemoteSingleAsync(id, ct);
|
||
|
||
if (remote != null && HasConflict(id, remote.UpdateTime))
|
||
{
|
||
// 后端也改了 → 后端版本获胜(MES 多用户安全策略)
|
||
UpsertLocalCache(remote);
|
||
ClearPending(id);
|
||
conflicts++;
|
||
_logger.Warning($"[供应商同步] 冲突:{local.SupplierName}({id}),后端版本获胜");
|
||
}
|
||
else
|
||
{
|
||
// 仅本地改了 → 安全推送
|
||
var (ok, isVersionConflict) = await PushEditAsync(local, ct);
|
||
if (isVersionConflict)
|
||
{
|
||
var fresh = await FetchRemoteSingleAsync(id, ct);
|
||
if (fresh != null) UpsertLocalCache(fresh);
|
||
ClearPending(id);
|
||
conflicts++;
|
||
_logger.Warning($"[供应商同步] 服务端版本冲突:{local.SupplierName}({id}),后端版本获胜");
|
||
}
|
||
else if (ok)
|
||
{
|
||
ClearPending(id);
|
||
pushed++;
|
||
_logger.Information($"[供应商同步] 推送成功:{local.SupplierName}({id})");
|
||
}
|
||
else
|
||
{
|
||
_pendingRetryCount.TryGetValue(id, out var retries);
|
||
retries++;
|
||
if (retries >= MaxPendingRetries)
|
||
{
|
||
_logger.Warning($"[供应商同步] 推送超过最大重试次数({MaxPendingRetries}),放弃:{id}");
|
||
ClearPending(id);
|
||
}
|
||
else
|
||
{
|
||
_pendingRetryCount[id] = retries;
|
||
_logger.Warning($"[供应商同步] 推送失败,下次重连重试({retries}/{MaxPendingRetries}):{id}");
|
||
}
|
||
}
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.Warning($"[供应商同步] 处理修改记录 {id} 时异常:{ex.Message}");
|
||
}
|
||
}
|
||
|
||
// ── 2. 推送离线期间删除的记录 ──────────────────────────────────────────
|
||
foreach (var id in new List<string>(_pendingLocalDeletedIds))
|
||
{
|
||
try
|
||
{
|
||
var remote = await FetchRemoteSingleAsync(id, ct);
|
||
if (remote == null) { ClearPending(id); continue; } // 后端已不存在,无需操作
|
||
|
||
if (HasConflict(id, remote.UpdateTime))
|
||
{
|
||
// 后端改动了但本地要删 → 保守策略:后端版本获胜,恢复到本地
|
||
UpsertLocalCache(remote);
|
||
ClearPending(id);
|
||
conflicts++;
|
||
_logger.Warning($"[供应商同步] 冲突:删除操作与后端改动冲突,已恢复记录 {id}");
|
||
}
|
||
else
|
||
{
|
||
if (await PushDeleteAsync(id, ct))
|
||
{
|
||
ClearPending(id);
|
||
pushed++;
|
||
}
|
||
else
|
||
{
|
||
_logger.Warning($"[供应商同步] 删除推送失败,下次重连重试:{id}");
|
||
}
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.Warning($"[供应商同步] 处理删除记录 {id} 时异常:{ex.Message}");
|
||
}
|
||
}
|
||
|
||
// ── 3. 推送离线期间新增的 local- 记录 ─────────────────────────────────
|
||
var localOnlyRecords = GetCacheSnapshot()
|
||
.Where(x => (x.Id ?? "").StartsWith("local-", StringComparison.OrdinalIgnoreCase))
|
||
.ToList();
|
||
|
||
foreach (var record in localOnlyRecords)
|
||
{
|
||
try
|
||
{
|
||
if (await PushAddAsync(record, ct))
|
||
{
|
||
RemoveFromLocalCache(record.Id!);
|
||
newPushed++;
|
||
// 下次 FetchRemoteListAsync 时后端会返回真实 ID 版本
|
||
}
|
||
else
|
||
{
|
||
_logger.Warning($"[供应商同步] 新增推送失败,下次重连重试:{record.SupplierName}");
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.Warning($"[供应商同步] 推送新增 {record.SupplierName} 时异常:{ex.Message}");
|
||
}
|
||
}
|
||
|
||
return new PushPendingResult(pushed, conflicts, newPushed);
|
||
}
|
||
|
||
// ── 私有:写操作核心 ──────────────────────────────────────────────────────
|
||
|
||
private async Task<bool> PostAndRefreshAsync(
|
||
string url, MesXslSupplier supplier, string action, string? supplierId, CancellationToken ct)
|
||
{
|
||
if (!supplier.TenantId.HasValue || supplier.TenantId <= 0) supplier.TenantId = DefaultTenantId;
|
||
if (string.IsNullOrWhiteSpace(supplier.Status)) supplier.Status = "0";
|
||
|
||
var content = new StringContent(JsonSerializer.Serialize(supplier, _jsonOpts), Encoding.UTF8, "application/json");
|
||
using var client = CreateClient();
|
||
var resp = await client.PostAsync(url, content, ct).ConfigureAwait(false);
|
||
|
||
if (IsDisconnectedResponse(resp))
|
||
{
|
||
if (string.IsNullOrWhiteSpace(supplier.Id))
|
||
{
|
||
// 新增:分配 local- ID,不进 pendingModifiedIds(通过 local- 前缀识别)
|
||
supplier.Id = $"local-{Guid.NewGuid():N}";
|
||
supplier.UpdateTime = DateTime.Now;
|
||
UpsertLocalCache(supplier);
|
||
}
|
||
else
|
||
{
|
||
// 编辑已有记录:捕获版本锚点(修改前的后端 UpdateTime)
|
||
var anchor = GetCacheSnapshot()
|
||
.FirstOrDefault(x => string.Equals(x.Id, supplier.Id, StringComparison.OrdinalIgnoreCase))?.UpdateTime;
|
||
supplier.UpdateTime = DateTime.Now;
|
||
UpsertLocalCache(supplier);
|
||
MarkLocalModified(supplier.Id, anchor);
|
||
}
|
||
_eventAggregator.GetEvent<SupplierChangedEvent>()
|
||
.Publish(new SupplierChangedPayload { Action = action, SupplierId = supplierId ?? supplier.Id });
|
||
return true;
|
||
}
|
||
|
||
var ok = resp.IsSuccessStatusCode && await IsSuccessResultAsync(resp, ct).ConfigureAwait(false);
|
||
if (ok)
|
||
{
|
||
UpsertLocalCache(supplier);
|
||
ClearPending(supplierId ?? supplier.Id ?? "");
|
||
_eventAggregator.GetEvent<SupplierChangedEvent>()
|
||
.Publish(new SupplierChangedPayload { Action = action, SupplierId = supplierId ?? supplier.Id });
|
||
}
|
||
return ok;
|
||
}
|
||
|
||
// ── 私有:重连推送方法 ────────────────────────────────────────────────────
|
||
|
||
private async Task<(bool Ok, bool IsVersionConflict)> PushEditAsync(MesXslSupplier supplier, CancellationToken ct)
|
||
{
|
||
var content = new StringContent(
|
||
JsonSerializer.Serialize(supplier, _jsonOpts), Encoding.UTF8, "application/json");
|
||
using var client = CreateClient();
|
||
var resp = await client.PostAsync(
|
||
$"{BaseUrl}/xslmes/mesXslSupplier/anon/edit?tenantId={DefaultTenantId}", content, ct)
|
||
.ConfigureAwait(false);
|
||
if (!resp.IsSuccessStatusCode) return (false, false);
|
||
try
|
||
{
|
||
var json = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||
using var doc = JsonDocument.Parse(json);
|
||
int code = 200;
|
||
if (doc.RootElement.TryGetProperty("code", out var codeEl)) code = codeEl.GetInt32();
|
||
if (code == 200) return (true, false);
|
||
if (doc.RootElement.TryGetProperty("message", out var msgEl))
|
||
{
|
||
var msg = msgEl.GetString() ?? "";
|
||
if (msg.Contains("已被他人修改")) return (false, true);
|
||
}
|
||
return (false, false);
|
||
}
|
||
catch { return (true, false); }
|
||
}
|
||
|
||
private async Task<bool> PushDeleteAsync(string id, CancellationToken ct)
|
||
{
|
||
using var client = CreateClient();
|
||
var url = $"{BaseUrl}/xslmes/mesXslSupplier/anon/delete?id={Uri.EscapeDataString(id)}&tenantId={DefaultTenantId}";
|
||
var resp = await client.DeleteAsync(url, ct).ConfigureAwait(false);
|
||
return resp.IsSuccessStatusCode && await IsSuccessResultAsync(resp, ct).ConfigureAwait(false);
|
||
}
|
||
|
||
private async Task<bool> PushAddAsync(MesXslSupplier supplier, CancellationToken ct)
|
||
{
|
||
var payload = Clone(supplier);
|
||
payload.Id = null; // 让后端生成真实 ID
|
||
var content = new StringContent(
|
||
JsonSerializer.Serialize(payload, _jsonOpts), Encoding.UTF8, "application/json");
|
||
using var client = CreateClient();
|
||
var resp = await client.PostAsync(
|
||
$"{BaseUrl}/xslmes/mesXslSupplier/anon/add?tenantId={DefaultTenantId}", content, ct)
|
||
.ConfigureAwait(false);
|
||
return resp.IsSuccessStatusCode && await IsSuccessResultAsync(resp, ct).ConfigureAwait(false);
|
||
}
|
||
|
||
private async Task<MesXslSupplier?> FetchRemoteSingleAsync(string id, CancellationToken ct)
|
||
{
|
||
try
|
||
{
|
||
var url = $"{BaseUrl}/xslmes/mesXslSupplier/anon/queryById?id={Uri.EscapeDataString(id)}&tenantId={DefaultTenantId}";
|
||
using var client = CreateClient();
|
||
var resp = await client.GetAsync(url, ct).ConfigureAwait(false);
|
||
if (!resp.IsSuccessStatusCode) return null;
|
||
var json = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||
using var doc = JsonDocument.Parse(json);
|
||
if (doc.RootElement.TryGetProperty("result", out var resultEl))
|
||
return resultEl.Deserialize<MesXslSupplier>(_jsonOpts);
|
||
return null;
|
||
}
|
||
catch { return null; }
|
||
}
|
||
|
||
// ── 私有:冲突检测 ────────────────────────────────────────────────────────
|
||
|
||
/// <summary>
|
||
/// 判断后端是否在断开期间修改了该记录:
|
||
/// 后端当前 UpdateTime 与本地锚点不同 → 两端都改了 → 冲突。
|
||
/// </summary>
|
||
private bool HasConflict(string id, DateTime? remoteUpdateTime)
|
||
{
|
||
if (!_anchors.TryGetValue(id, out var anchor)) return false;
|
||
return remoteUpdateTime != anchor;
|
||
}
|
||
|
||
private static bool IsDisconnectedResponse(HttpResponseMessage resp) =>
|
||
(int)resp.StatusCode == 499;
|
||
|
||
// ── 私有:pending 状态管理 ────────────────────────────────────────────────
|
||
|
||
private void MarkLocalModified(string id, DateTime? anchor)
|
||
{
|
||
lock (_cacheLock)
|
||
{
|
||
_pendingLocalDeletedIds.Remove(id);
|
||
_pendingLocalModifiedIds.Add(id);
|
||
if (!_anchors.ContainsKey(id)) _anchors[id] = anchor; // 只记第一次(保留原始锚点)
|
||
SavePendingToDiskUnsafe();
|
||
}
|
||
}
|
||
|
||
private void MarkLocalDeleted(string id, DateTime? anchor)
|
||
{
|
||
lock (_cacheLock)
|
||
{
|
||
_pendingLocalModifiedIds.Remove(id);
|
||
_pendingLocalDeletedIds.Add(id);
|
||
if (!_anchors.ContainsKey(id)) _anchors[id] = anchor;
|
||
SavePendingToDiskUnsafe();
|
||
}
|
||
}
|
||
|
||
private void ClearPending(string id)
|
||
{
|
||
lock (_cacheLock)
|
||
{
|
||
_pendingLocalModifiedIds.Remove(id);
|
||
_pendingLocalDeletedIds.Remove(id);
|
||
_anchors.Remove(id);
|
||
_pendingRetryCount.Remove(id);
|
||
SavePendingToDiskUnsafe();
|
||
}
|
||
}
|
||
|
||
// ── 私有:缓存合并 ────────────────────────────────────────────────────────
|
||
|
||
/// <summary>
|
||
/// 后端全量数据与本地 pending 改动合并,保证本地未同步改动不被覆盖。
|
||
/// </summary>
|
||
private void MergeIntoCache(List<MesXslSupplier> backendList)
|
||
{
|
||
bool hasPending = _pendingLocalModifiedIds.Count > 0 || _pendingLocalDeletedIds.Count > 0;
|
||
if (!hasPending)
|
||
{
|
||
_localCache = backendList.Select(Clone).ToList();
|
||
return;
|
||
}
|
||
|
||
var localById = _localCache.ToDictionary(x => x.Id ?? "", StringComparer.OrdinalIgnoreCase);
|
||
var merged = new List<MesXslSupplier>(backendList.Count + 8);
|
||
|
||
foreach (var remote in backendList)
|
||
{
|
||
var id = remote.Id ?? "";
|
||
if (_pendingLocalDeletedIds.Contains(id)) continue; // 保持本地删除状态
|
||
if (_pendingLocalModifiedIds.Contains(id) && localById.TryGetValue(id, out var local))
|
||
merged.Add(Clone(local)); // 本地版本优先(尚未推送成功)
|
||
else
|
||
merged.Add(Clone(remote));
|
||
}
|
||
|
||
// 保留本地新增(local- 前缀,尚未推送到后端)
|
||
foreach (var local in _localCache)
|
||
{
|
||
if ((local.Id ?? "").StartsWith("local-", StringComparison.OrdinalIgnoreCase))
|
||
merged.Add(Clone(local));
|
||
}
|
||
|
||
// 安全保留:pending-modified 中后端未返回的条目(后端异常情况)
|
||
foreach (var modId in _pendingLocalModifiedIds)
|
||
{
|
||
if (!merged.Any(x => string.Equals(x.Id, modId, StringComparison.OrdinalIgnoreCase))
|
||
&& localById.TryGetValue(modId, out var orphan))
|
||
merged.Add(Clone(orphan));
|
||
}
|
||
|
||
_localCache = merged;
|
||
}
|
||
|
||
// ── 私有:磁盘持久化 ──────────────────────────────────────────────────────
|
||
|
||
private async Task<List<MesXslSupplier>> FetchRemoteListAsync(CancellationToken ct)
|
||
{
|
||
var query = HttpUtility.ParseQueryString(string.Empty);
|
||
query["pageNo"] = "1";
|
||
query["pageSize"] = "10000";
|
||
query["tenantId"] = DefaultTenantId.ToString();
|
||
var url = $"{BaseUrl}/xslmes/mesXslSupplier/anon/list?{query}";
|
||
using var client = CreateClient();
|
||
var resp = await client.GetAsync(url, ct).ConfigureAwait(false);
|
||
resp.EnsureSuccessStatusCode();
|
||
var json = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||
using var doc = JsonDocument.Parse(json);
|
||
return doc.RootElement.GetProperty("result").GetProperty("records")
|
||
.Deserialize<List<MesXslSupplier>>(_jsonOpts) ?? new();
|
||
}
|
||
|
||
private List<MesXslSupplier> GetCacheSnapshot()
|
||
{
|
||
lock (_cacheLock) return _localCache.Select(Clone).ToList();
|
||
}
|
||
|
||
private void LoadCacheFromDisk()
|
||
{
|
||
try
|
||
{
|
||
if (!File.Exists(_cacheFilePath)) return;
|
||
_localCache = JsonSerializer.Deserialize<List<MesXslSupplier>>(
|
||
File.ReadAllText(_cacheFilePath), _jsonOpts) ?? new();
|
||
}
|
||
catch { _localCache = new(); }
|
||
}
|
||
|
||
private void SaveCacheToDiskUnsafe() =>
|
||
File.WriteAllText(_cacheFilePath, JsonSerializer.Serialize(_localCache, _jsonOpts));
|
||
|
||
private void LoadPendingFromDisk()
|
||
{
|
||
try
|
||
{
|
||
if (!File.Exists(_pendingFilePath)) return;
|
||
var state = JsonSerializer.Deserialize<PendingState>(File.ReadAllText(_pendingFilePath)) ?? new();
|
||
foreach (var id in state.Modified) _pendingLocalModifiedIds.Add(id);
|
||
foreach (var id in state.Deleted) _pendingLocalDeletedIds.Add(id);
|
||
foreach (var (id, timeStr) in state.Anchors)
|
||
_anchors[id] = timeStr != null && DateTime.TryParse(timeStr, out var dt) ? dt : null;
|
||
}
|
||
catch { }
|
||
}
|
||
|
||
private void SavePendingToDiskUnsafe()
|
||
{
|
||
var state = new PendingState
|
||
{
|
||
Modified = _pendingLocalModifiedIds.ToList(),
|
||
Deleted = _pendingLocalDeletedIds.ToList(),
|
||
Anchors = _anchors.ToDictionary(
|
||
kv => kv.Key,
|
||
kv => kv.Value?.ToString("yyyy-MM-dd HH:mm:ss"))
|
||
};
|
||
File.WriteAllText(_pendingFilePath, JsonSerializer.Serialize(state));
|
||
}
|
||
|
||
private void UpsertLocalCache(MesXslSupplier supplier)
|
||
{
|
||
lock (_cacheLock)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(supplier.Id))
|
||
{
|
||
_localCache.Insert(0, Clone(supplier));
|
||
}
|
||
else
|
||
{
|
||
var idx = _localCache.FindIndex(x =>
|
||
string.Equals(x.Id, supplier.Id, StringComparison.OrdinalIgnoreCase));
|
||
if (idx >= 0) _localCache[idx] = Clone(supplier);
|
||
else _localCache.Insert(0, Clone(supplier));
|
||
}
|
||
SaveCacheToDiskUnsafe();
|
||
}
|
||
}
|
||
|
||
private void RemoveFromLocalCache(string id)
|
||
{
|
||
lock (_cacheLock)
|
||
{
|
||
_localCache.RemoveAll(x => string.Equals(x.Id, id, StringComparison.OrdinalIgnoreCase));
|
||
SaveCacheToDiskUnsafe();
|
||
}
|
||
}
|
||
|
||
private void UpdateLocalStatus(string id, string status)
|
||
{
|
||
lock (_cacheLock)
|
||
{
|
||
var item = _localCache.FirstOrDefault(x =>
|
||
string.Equals(x.Id, id, StringComparison.OrdinalIgnoreCase));
|
||
if (item != null) { item.Status = status; SaveCacheToDiskUnsafe(); }
|
||
}
|
||
}
|
||
|
||
private static async Task<bool> IsSuccessResultAsync(HttpResponseMessage resp, CancellationToken ct)
|
||
{
|
||
try
|
||
{
|
||
var json = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||
using var doc = JsonDocument.Parse(json);
|
||
if (doc.RootElement.TryGetProperty("code", out var code)) return code.GetInt32() == 200;
|
||
if (doc.RootElement.TryGetProperty("success", out var success)) return success.GetBoolean();
|
||
}
|
||
catch { }
|
||
return true;
|
||
}
|
||
|
||
private static MesXslSupplier Clone(MesXslSupplier x) => new()
|
||
{
|
||
Id = x.Id, SupplierCode = x.SupplierCode, SupplierName = x.SupplierName,
|
||
SupplierShortName = x.SupplierShortName, ErpCode = x.ErpCode, Remark = x.Remark,
|
||
Status = x.Status, TenantId = x.TenantId, CreateBy = x.CreateBy,
|
||
CreateTime = x.CreateTime, UpdateBy = x.UpdateBy, UpdateTime = x.UpdateTime,
|
||
SysOrgCode = x.SysOrgCode
|
||
};
|
||
|
||
private sealed class PendingState
|
||
{
|
||
public List<string> Modified { get; set; } = new();
|
||
public List<string> Deleted { get; set; } = new();
|
||
public Dictionary<string, string?> Anchors { get; set; } = new();
|
||
}
|
||
|
||
private sealed class NullableDateTimeJsonConverter : JsonConverter<DateTime?>
|
||
{
|
||
public override DateTime? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||
=> reader.TokenType == JsonTokenType.String
|
||
&& DateTime.TryParse(reader.GetString(), out var dt) ? dt : null;
|
||
public override void Write(Utf8JsonWriter writer, DateTime? value, JsonSerializerOptions options)
|
||
=> writer.WriteStringValue(value?.ToString("yyyy-MM-dd HH:mm:ss"));
|
||
}
|
||
}
|