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 _localCache = new(); private const int MaxPendingRetries = 5; // 断开期间被本地修改的条目(含版本锚点),重连后推送到后端 private readonly HashSet _pendingLocalModifiedIds = new(StringComparer.OrdinalIgnoreCase); // 断开期间被本地删除的条目 private readonly HashSet _pendingLocalDeletedIds = new(StringComparer.OrdinalIgnoreCase); // 版本锚点:最后一次从后端同步时该条目的 UpdateTime,用于冲突检测 private readonly Dictionary _anchors = new(StringComparer.OrdinalIgnoreCase); // 每条待推送记录的重试次数,超过上限后放弃 private readonly Dictionary _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("JeecgIntegration:BaseUrl") ?? "http://localhost:8080/jeecg-boot").TrimEnd('/'); private int DefaultTenantId => (int?)_configuration.GetValue("JeecgIntegration:DefaultTenantId") ?? 1002; private HttpClient CreateClient() => _httpClientFactory.CreateClient("JeecgApi"); // ── 公开接口 ────────────────────────────────────────────────────────────── public async Task PageAsync( int pageNo, int pageSize, string? supplierCode = null, string? supplierName = null, string? supplierShortName = null, string? erpCode = null, string? status = null, CancellationToken ct = default) { List source; try { source = _networkMonitor.IsOnline ? await FetchRemoteListAsync(ct).ConfigureAwait(false) : GetCacheSnapshot(); lock (_cacheLock) { MergeIntoCache(source); SaveCacheToDiskUnsafe(); } source = GetCacheSnapshot(); } catch { source = GetCacheSnapshot(); } IEnumerable 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 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(_jsonOpts); } } return GetCacheSnapshot().FirstOrDefault(x => string.Equals(x.Id, id, StringComparison.OrdinalIgnoreCase)); } public Task AddAsync(MesXslSupplier supplier, CancellationToken ct = default) => PostAndRefreshAsync( $"{BaseUrl}/xslmes/mesXslSupplier/anon/add?tenantId={DefaultTenantId}", supplier, "add", supplier.Id, ct); public Task EditAsync(MesXslSupplier supplier, CancellationToken ct = default) => PostAndRefreshAsync( $"{BaseUrl}/xslmes/mesXslSupplier/anon/edit?tenantId={DefaultTenantId}", supplier, "edit", supplier.Id, ct); public async Task 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() .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() .Publish(new SupplierChangedPayload { Action = "delete", SupplierId = id }); } return ok; } public async Task 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() .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() .Publish(new SupplierChangedPayload { Action = "status", SupplierId = id }); } return ok; } /// /// 重连后将离线期间的本地改动推送到后端,并检测冲突。 /// 调用方应在此方法完成后再触发 UI 刷新,确保页面看到的是推送结果。 /// public async Task PushPendingOnReconnectAsync(CancellationToken ct = default) { int pushed = 0, conflicts = 0, newPushed = 0; // ── 1. 推送离线期间修改的现有记录 ───────────────────────────────────── foreach (var id in new List(_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(_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 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() .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() .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 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 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 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(_jsonOpts); return null; } catch { return null; } } // ── 私有:冲突检测 ──────────────────────────────────────────────────────── /// /// 判断后端是否在断开期间修改了该记录: /// 后端当前 UpdateTime 与本地锚点不同 → 两端都改了 → 冲突。 /// 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(); } } // ── 私有:缓存合并 ──────────────────────────────────────────────────────── /// /// 后端全量数据与本地 pending 改动合并,保证本地未同步改动不被覆盖。 /// private void MergeIntoCache(List 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(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> 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>(_jsonOpts) ?? new(); } private List GetCacheSnapshot() { lock (_cacheLock) return _localCache.Select(Clone).ToList(); } private void LoadCacheFromDisk() { try { if (!File.Exists(_cacheFilePath)) return; _localCache = JsonSerializer.Deserialize>( 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(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 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 Modified { get; set; } = new(); public List Deleted { get; set; } = new(); public Dictionary Anchors { get; set; } = new(); } private sealed class NullableDateTimeJsonConverter : JsonConverter { 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")); } }