using Microsoft.Extensions.Configuration; using SqlSugar; using System.Collections.Concurrent; using System.Globalization; using System.Net.Http; using System.Text; using System.Net.Http.Json; using System.Security.Cryptography; using System.Text.Json; using YY.Admin.Core.Session; using YY.Admin.Core.Util; using YY.Admin.Services.Service.Config; using YY.Admin.Services.Service.Jeecg; namespace YY.Admin.Services.Service.Auth { public class SysAuthService : ISysAuthService, ISingletonDependency { private SysUser? _currentUser; public SysUser? CurrentUser => _currentUser; public event EventHandler? UserChanged; private readonly ISysCacheService _sysCacheService; private readonly ISqlSugarClient _dbContext; private readonly ISysConfigService _sysConfigService; private readonly IEventAggregator _eventAggregator; private readonly IConfiguration _configuration; private readonly HttpClient _httpClient; private readonly ILoggerService _logger; public SysAuthService( ISqlSugarClient dbContext, ISysCacheService sysCacheService, ISysConfigService sysConfigService, IEventAggregator eventAggregator, IConfiguration configuration, HttpClient httpClient, ILoggerService logger) { _dbContext=dbContext; _sysCacheService=sysCacheService; _sysConfigService=sysConfigService; _eventAggregator = eventAggregator; _configuration = configuration; _httpClient = httpClient; _logger = logger; } // 添加Token存储 private static readonly ConcurrentDictionary _tokenStore = new ConcurrentDictionary(); private static bool _localIdentityResetDone = false; private static long _localIdSeed = DateTime.UtcNow.Ticks; public async Task LoginAsync(LoginInput request) { string? jeecgErrorMessage = null; try { var jeecgEnabled = _configuration.GetValue("JeecgIntegration:Enabled"); var preferLocal = _configuration.GetValue("JeecgIntegration:PreferLocalLogin", true); var fallbackToLocal = _configuration.GetValue("JeecgIntegration:FallbackToLocal", true); var backendReachable = await IsJeecgBackendReachableAsync(); // 未启用 Jeecg:仅本地 if (!jeecgEnabled) { return await AuthenticateAgainstJeecgMirrorTableAsync(request, jeecgFailureHint: null, localFirstOfflineHint: true); } // 后端断开:仅本地新表登录,不再依赖后端 if (!backendReachable) { return await AuthenticateAgainstJeecgMirrorTableAsync( request, jeecgFailureHint: "Jeecg服务拒绝连接,请确认后端服务已启动", localFirstOfflineHint: true); } // 工控离线优先:先本地库(不连 MES 即可用种子/已同步账号),失败再尝试 Jeecg if (preferLocal) { var localFirst = await AuthenticateAgainstJeecgMirrorTableAsync(request, jeecgFailureHint: null, localFirstOfflineHint: true); if (localFirst.Success) { return localFirst; } var jeecgResult = await LoginByJeecgAsync(request); if (jeecgResult.Success) { return jeecgResult; } jeecgErrorMessage = jeecgResult.Message; if (!fallbackToLocal) { return jeecgResult; } // 本地已失败且已计次,不再二次验密;合并 Jeecg 原因便于排查 return MergeLocalLoginFailureWithJeecg(localFirst, jeecgErrorMessage); } // 在线优先:先 Jeecg,失败再本地(传统 SSO) var jeecgFirst = await LoginByJeecgAsync(request); if (jeecgFirst.Success) { return jeecgFirst; } jeecgErrorMessage = jeecgFirst.Message; if (!fallbackToLocal) { return jeecgFirst; } return await AuthenticateAgainstJeecgMirrorTableAsync(request, jeecgFailureHint: jeecgErrorMessage, localFirstOfflineHint: false); } catch (Exception ex) { var friendlyError = ToFriendlyJeecgErrorMessage(ex.Message); return new LoginOutput { Success = false, Message = string.IsNullOrWhiteSpace(jeecgErrorMessage) ? $"登录失败:{friendlyError}" : $"{jeecgErrorMessage}({friendlyError})" }; } } /// /// 本地账号表(jeecg_sys_user)登录。 /// 后端断开时仅依赖此表,不调用远端接口。 /// private async Task AuthenticateAgainstJeecgMirrorTableAsync( LoginInput request, string? jeecgFailureHint, bool localFirstOfflineHint) { var keyPasswordErrorTimes = $"{CacheConst.KeyPasswordErrorTimes}{request.Username}"; var passwordErrorTimes = _sysCacheService.Get(keyPasswordErrorTimes); var passwordMaxErrorTimes = await _sysConfigService.GetConfigValue(ConfigConst.SysPasswordMaxErrorTimes); if (passwordMaxErrorTimes < 1) { passwordMaxErrorTimes = 10; } if (passwordErrorTimes >= passwordMaxErrorTimes) { return new LoginOutput { Success = false, Message = "密码错误次数过多,账号已锁定,请半小时后重试!" }; } var mirrorCandidates = await _dbContext.Queryable().ClearFilter() .WhereIF(request.TenantId > 0, u => u.LoginTenantId == request.TenantId) .WhereIF(!string.IsNullOrWhiteSpace(request.Username), u => u.Username == request.Username || (u.Phone != null && u.Phone == request.Username)) .Take(1) .ToListAsync(); var mirrorUser = mirrorCandidates.FirstOrDefault(); if (mirrorUser == null) { return new LoginOutput { Success = false, Message = string.IsNullOrWhiteSpace(jeecgFailureHint) ? "用户名或密码错误" : $"用户名或密码错误。{jeecgFailureHint}" }; } if (mirrorUser.Status.HasValue && mirrorUser.Status.Value != 1) { return new LoginOutput { Success = false, Message = "账号已冻结" }; } var sessionUser = BuildSessionUserFromMirrorUser(mirrorUser); if (VerifyPassword(request.Password, keyPasswordErrorTimes, passwordErrorTimes, sessionUser)) { _currentUser = sessionUser; UserChanged?.Invoke(this, _currentUser); _sysCacheService.Remove(keyPasswordErrorTimes); string okMsg; if (localFirstOfflineHint) { okMsg = "登录成功(本地验证;与 MES 断连时数据保留在本机,恢复连接后可再与后台同步)"; } else if (!string.IsNullOrWhiteSpace(jeecgFailureHint)) { okMsg = "登录成功(后台不可达或 Jeecg 未通过,已使用本地账号验证)"; } else { okMsg = "登录成功"; } return new LoginOutput { Success = true, Message = okMsg, User = _currentUser, Token = await GenerateToken(sessionUser) }; } return new LoginOutput { Success = false, Message = string.IsNullOrWhiteSpace(jeecgFailureHint) ? "用户名或密码错误" : $"用户名或密码错误。{jeecgFailureHint}" }; } /// /// 将 jeecg_sys_user 转换为本地会话所需 SysUser 结构。 /// private SysUser BuildSessionUserFromMirrorUser(JeecgSysUser mirrorUser) { long localId; if (!long.TryParse(mirrorUser.Id, NumberStyles.Integer, CultureInfo.InvariantCulture, out localId) || localId <= 0) { localId = Interlocked.Increment(ref _localIdSeed); } return new SysUser { Id = localId, Account = mirrorUser.Username ?? string.Empty, RealName = string.IsNullOrWhiteSpace(mirrorUser.Realname) ? (mirrorUser.Username ?? string.Empty) : mirrorUser.Realname!, Password = mirrorUser.Password ?? string.Empty, JeecgPasswordSalt = mirrorUser.Salt, Phone = mirrorUser.Phone, Email = mirrorUser.Email, JobNum = mirrorUser.WorkNo, Status = mirrorUser.Status == 1 ? StatusEnum.Enable : StatusEnum.Disable, TenantId = mirrorUser.LoginTenantId.HasValue ? mirrorUser.LoginTenantId.Value : (_configuration.GetValue("JeecgIntegration:DefaultTenantId") ?? 1300000000001), AccountType = AccountTypeEnum.NormalUser, OrgId = 0, PosId = 0 }; } /// /// 快速探测 Jeecg 后端是否连通(登录前短超时)。 /// private async Task IsJeecgBackendReachableAsync() { var baseUrl = _configuration.GetValue("JeecgIntegration:BaseUrl")?.TrimEnd('/'); var userListPath = _configuration.GetValue("JeecgIntegration:UserListPath") ?? "/sys/user/scada/queryUser"; if (string.IsNullOrWhiteSpace(baseUrl)) { return false; } var probeUrl = $"{baseUrl}{userListPath}?pageNo=1&pageSize=1&includeDetail=false"; try { using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); using var req = new HttpRequestMessage(HttpMethod.Get, probeUrl); using var resp = await _httpClient.SendAsync(req, cts.Token); return resp.IsSuccessStatusCode; } catch { return false; } } /// /// 本地已失败时附加 Jeecg 原因,且不重复调用验密(避免错误次数翻倍) /// private static LoginOutput MergeLocalLoginFailureWithJeecg(LoginOutput localFailure, string? jeecgMessage) { if (localFailure.Success) { return localFailure; } // 锁定、冻结等保持原样 if (!string.Equals(localFailure.Message, "用户名或密码错误", StringComparison.Ordinal)) { return string.IsNullOrWhiteSpace(jeecgMessage) ? localFailure : new LoginOutput { Success = false, Message = $"{localFailure.Message}。{jeecgMessage}" }; } // 后台不可达时,避免让用户误以为仅是「密码错」:补充本地 Jeecg 盐字段说明 if (!string.IsNullOrWhiteSpace(jeecgMessage) && IsJeecgTransportUnreachableMessage(jeecgMessage)) { return new LoginOutput { Success = false, Message = $"{jeecgMessage} 本地账号校验未通过时,请确认已从 MES 同步 password 与 jeecg_password_salt,且登录名与 Jeecg 的 username 一致。" }; } return new LoginOutput { Success = false, Message = string.IsNullOrWhiteSpace(jeecgMessage) ? localFailure.Message : $"{localFailure.Message}。{jeecgMessage}" }; } /// /// 判断是否为 Jeecg 网络不可达类错误(中英文字符串均兼容) /// private static bool IsJeecgTransportUnreachableMessage(string message) { if (string.IsNullOrWhiteSpace(message)) { return false; } return message.Contains("Connection refused", StringComparison.OrdinalIgnoreCase) || message.Contains("积极拒绝", StringComparison.Ordinal) || message.Contains("拒绝连接", StringComparison.Ordinal) || message.Contains("No connection could be made", StringComparison.OrdinalIgnoreCase) || message.Contains("无法连接", StringComparison.Ordinal) || message.Contains("timed out", StringComparison.OrdinalIgnoreCase) || message.Contains("超时", StringComparison.Ordinal) || message.Contains("No such host", StringComparison.OrdinalIgnoreCase) || message.Contains("不知道这样的主机", StringComparison.Ordinal); } /// /// 通过 Jeecg 接口进行认证,并映射到本地用户 /// private async Task LoginByJeecgAsync(LoginInput request) { try { var baseUrl = _configuration.GetValue("JeecgIntegration:BaseUrl")?.TrimEnd('/'); if (string.IsNullOrWhiteSpace(baseUrl)) { return new LoginOutput { Success = false, Message = "JeecgIntegration 已启用,但未配置 BaseUrl" }; } var loginPath = _configuration.GetValue("JeecgIntegration:LoginPath") ?? "/sys/login"; var userInfoPath = _configuration.GetValue("JeecgIntegration:UserInfoPath") ?? "/sys/user/getUserInfo"; var loginUrl = $"{baseUrl}{loginPath}"; var userInfoUrl = $"{baseUrl}{userInfoPath}"; var payload = new Dictionary { ["username"] = request.Username, ["password"] = request.Password, ["rememberMe"] = request.RememberMe }; var captcha = _configuration.GetValue("JeecgIntegration:Captcha"); var checkKey = _configuration.GetValue("JeecgIntegration:CheckKey"); if (!string.IsNullOrWhiteSpace(captcha)) payload["captcha"] = captcha; if (!string.IsNullOrWhiteSpace(checkKey)) payload["checkKey"] = checkKey; using var loginResponse = await _httpClient.PostAsJsonAsync(loginUrl, payload); if (!loginResponse.IsSuccessStatusCode) { return new LoginOutput { Success = false, Message = $"Jeecg登录请求失败:HTTP {(int)loginResponse.StatusCode}" }; } var loginJson = await loginResponse.Content.ReadAsStringAsync(); using var loginDoc = JsonDocument.Parse(loginJson); var loginRoot = loginDoc.RootElement; var loginSuccess = loginRoot.TryGetProperty("success", out var successEl) && successEl.GetBoolean(); if (!loginSuccess) { var loginMsg = loginRoot.TryGetProperty("message", out var msgEl) ? msgEl.GetString() : "Jeecg登录失败"; return new LoginOutput { Success = false, Message = loginMsg ?? "Jeecg登录失败" }; } string? jeecgToken = null; JsonElement tenantListElement = default; bool hasTenantList = false; if (loginRoot.TryGetProperty("result", out var resultEl) && resultEl.ValueKind == JsonValueKind.Object && resultEl.TryGetProperty("token", out var tokenEl)) { jeecgToken = tokenEl.GetString(); if (resultEl.TryGetProperty("tenantList", out var tenantListEl) && tenantListEl.ValueKind == JsonValueKind.Array) { tenantListElement = tenantListEl; hasTenantList = true; } } if (string.IsNullOrWhiteSpace(jeecgToken)) { return new LoginOutput { Success = false, Message = "Jeecg登录成功但未返回 token" }; } using var userReq = new HttpRequestMessage(HttpMethod.Get, userInfoUrl); userReq.Headers.TryAddWithoutValidation("X-Access-Token", jeecgToken); using var userResponse = await _httpClient.SendAsync(userReq); if (!userResponse.IsSuccessStatusCode) { return new LoginOutput { Success = false, Message = $"获取Jeecg用户信息失败:HTTP {(int)userResponse.StatusCode}" }; } var userJson = await userResponse.Content.ReadAsStringAsync(); using var userDoc = JsonDocument.Parse(userJson); var userRoot = userDoc.RootElement; if (!(userRoot.TryGetProperty("success", out var userSuccessEl) && userSuccessEl.GetBoolean())) { var userMsg = userRoot.TryGetProperty("message", out var userMsgEl) ? userMsgEl.GetString() : "获取Jeecg用户信息失败"; return new LoginOutput { Success = false, Message = userMsg ?? "获取Jeecg用户信息失败" }; } var remoteUsername = request.Username; string? remoteRealName = null; string? remoteNickName = null; string? remotePhone = null; string? remoteEmail = null; string? remoteAvatar = null; string? remoteAddress = null; string? remoteOfficePhone = null; string? remoteJobNum = null; string? remoteIdCardNum = null; string? remoteRemark = null; DateTime? remoteBirthday = null; GenderEnum? remoteSex = null; StatusEnum? remoteStatus = null; long? remoteLoginTenantId = null; DateTime? remoteJeecgUpdateTime = null; long? remoteJeecgUserId = null; string? remoteJeecgIdRaw = null; if (userRoot.TryGetProperty("result", out var userResultEl) && userResultEl.ValueKind == JsonValueKind.Object && userResultEl.TryGetProperty("userInfo", out var userInfoEl) && userInfoEl.ValueKind == JsonValueKind.Object) { if (userInfoEl.TryGetProperty("id", out var jeecgUserIdEl)) { remoteJeecgUserId = TryParseJeecgSnowflakeUserIdFromJson(jeecgUserIdEl); remoteJeecgIdRaw = jeecgUserIdEl.ValueKind == JsonValueKind.String ? jeecgUserIdEl.GetString() : jeecgUserIdEl.ValueKind == JsonValueKind.Number ? jeecgUserIdEl.GetRawText() : null; } if (userInfoEl.TryGetProperty("username", out var usernameEl)) remoteUsername = usernameEl.GetString() ?? remoteUsername; if (userInfoEl.TryGetProperty("realname", out var realnameEl)) remoteRealName = realnameEl.GetString(); if (userInfoEl.TryGetProperty("nickname", out var nicknameEl)) remoteNickName = nicknameEl.GetString(); if (userInfoEl.TryGetProperty("phone", out var phoneEl)) remotePhone = phoneEl.GetString(); if (userInfoEl.TryGetProperty("email", out var emailEl)) remoteEmail = emailEl.GetString(); if (userInfoEl.TryGetProperty("avatar", out var avatarEl)) remoteAvatar = avatarEl.GetString(); if (userInfoEl.TryGetProperty("address", out var addressEl)) remoteAddress = addressEl.GetString(); if (userInfoEl.TryGetProperty("telephone", out var telephoneEl)) remoteOfficePhone = telephoneEl.GetString(); if (userInfoEl.TryGetProperty("workNo", out var workNoEl)) remoteJobNum = workNoEl.GetString(); if (userInfoEl.TryGetProperty("idCard", out var idCardEl)) remoteIdCardNum = idCardEl.GetString(); if (userInfoEl.TryGetProperty("description", out var descriptionEl)) remoteRemark = descriptionEl.GetString(); if (userInfoEl.TryGetProperty("birthday", out var birthdayEl) && birthdayEl.ValueKind == JsonValueKind.String && DateTime.TryParse(birthdayEl.GetString(), out var birthday)) { remoteBirthday = birthday; } if (userInfoEl.TryGetProperty("sex", out var sexEl)) { remoteSex = ResolveUserSex(sexEl); } if (userInfoEl.TryGetProperty("status", out var statusEl)) { remoteStatus = ResolveUserStatus(statusEl); } if (userInfoEl.TryGetProperty("loginTenantId", out var tenantIdEl) && tenantIdEl.TryGetInt64(out var tenantId)) { remoteLoginTenantId = tenantId; } if (userInfoEl.TryGetProperty("updateTime", out var loginUpdateEl)) { remoteJeecgUpdateTime = TryParseJeecgRemoteDateTime(loginUpdateEl); } } // 按配置清空本地用户/角色/租户数据(仅当前进程执行一次) var resetLocalIdentity = _configuration.GetValue("JeecgIntegration:ResetLocalIdentityDataOnJeecgLogin", false); if (resetLocalIdentity && !_localIdentityResetDone) { await ResetLocalIdentityDataAsync(); _localIdentityResetDone = true; } var useJeecgUserIdAsLocalPk = _configuration.GetValue("JeecgIntegration:UseJeecgUserIdAsLocalPrimaryKey", true); var localUser = await _dbContext.Queryable() .Includes(u => u.SysOrg) .ClearFilter() .Where(u => u.Account == remoteUsername || (!string.IsNullOrEmpty(remotePhone) && u.Phone == remotePhone)) .FirstAsync(); if (localUser == null) { var autoProvision = _configuration.GetValue("JeecgIntegration:AutoProvisionLocalUser", true); if (!autoProvision) { return new LoginOutput { Success = false, Message = $"Jeecg认证成功,但本地未找到账号映射:{remoteUsername}。请先在工控端创建同名账号。" }; } var templateUser = await _dbContext.Queryable().ClearFilter().OrderBy(u => u.Id).FirstAsync(); var defaultTenantId = _configuration.GetValue("JeecgIntegration:DefaultTenantId"); var targetTenantId = defaultTenantId ?? remoteLoginTenantId ?? templateUser?.TenantId ?? 1300000000001; var newUser = new SysUser { Account = remoteUsername, RealName = string.IsNullOrWhiteSpace(remoteRealName) ? remoteUsername : remoteRealName, NickName = remoteNickName, Password = CryptogramUtil.Encrypt(Guid.NewGuid().ToString("N")), Sex = remoteSex ?? GenderEnum.Unknown, Birthday = remoteBirthday, Phone = remotePhone, OfficePhone = remoteOfficePhone, Email = remoteEmail, Avatar = remoteAvatar, Address = remoteAddress, JobNum = remoteJobNum, IdCardNum = remoteIdCardNum, Remark = remoteRemark, Status = remoteStatus ?? StatusEnum.Enable, AccountType = targetTenantId == (defaultTenantId ?? 0) ? AccountTypeEnum.SysAdmin : AccountTypeEnum.NormalUser, TenantId = targetTenantId, OrgId = templateUser?.OrgId ?? 0, PosId = templateUser?.PosId ?? 0, JeecgUpdateTime = remoteJeecgUpdateTime, CreateTime = DateTime.Now, UpdateTime = DateTime.Now }; long assignUserId; if (useJeecgUserIdAsLocalPk && remoteJeecgUserId.HasValue && remoteJeecgUserId.Value > 0) { var idOccupied = await _dbContext.Queryable().ClearFilter().AnyAsync(u => u.Id == remoteJeecgUserId.Value); assignUserId = idOccupied ? await TakeNextSysUserIdAsync() : remoteJeecgUserId.Value; } else { assignUserId = await TakeNextSysUserIdAsync(); } newUser.Id = assignUserId; if (!string.IsNullOrWhiteSpace(remoteJeecgIdRaw)) { newUser.JeecgBizUserId = TruncateJeecgSyncField(remoteJeecgIdRaw, 64); } await _dbContext.Insertable(newUser).ExecuteCommandAsync(); localUser = await _dbContext.Queryable() .Includes(u => u.SysOrg) .ClearFilter() .Where(u => u.Account == remoteUsername) .FirstAsync(); if (localUser == null) { return new LoginOutput { Success = false, Message = "Jeecg认证成功,但本地自动建档失败。" }; } } // 已存在用户:将本地主键迁移为 Jeecg userInfo.id(与接口一致) if (useJeecgUserIdAsLocalPk && remoteJeecgUserId.HasValue && remoteJeecgUserId.Value > 0) { if (localUser.Id != remoteJeecgUserId.Value) { var idBlocked = await _dbContext.Queryable().ClearFilter() .Where(u => u.Id == remoteJeecgUserId.Value && u.Account != remoteUsername) .AnyAsync(); if (!idBlocked) { var oldUid = localUser.Id; await TryMigrateSysUserPrimaryKeyToJeecgIdAsync(oldUid, remoteJeecgUserId.Value); _sysCacheService.Remove($"jeecg:token:{oldUid}"); localUser = await _dbContext.Queryable() .Includes(u => u.SysOrg) .ClearFilter() .Where(u => u.Account == remoteUsername) .FirstAsync() ?? localUser; } } } if (!string.IsNullOrWhiteSpace(remoteJeecgIdRaw)) { localUser.JeecgBizUserId = TruncateJeecgSyncField(remoteJeecgIdRaw, 64); } // Jeecg字段全量同步到本地用户(以后端为准) if (!string.IsNullOrWhiteSpace(remoteRealName)) localUser.RealName = remoteRealName; localUser.NickName = remoteNickName; localUser.Phone = remotePhone; localUser.Email = remoteEmail; localUser.Avatar = remoteAvatar; localUser.Address = remoteAddress; localUser.OfficePhone = remoteOfficePhone; localUser.JobNum = remoteJobNum; localUser.IdCardNum = remoteIdCardNum; localUser.Remark = remoteRemark; if (remoteBirthday.HasValue) localUser.Birthday = remoteBirthday; if (remoteSex.HasValue) localUser.Sex = remoteSex.Value; if (remoteStatus.HasValue) localUser.Status = remoteStatus.Value; if (remoteLoginTenantId.HasValue && remoteLoginTenantId.Value > 0) localUser.TenantId = remoteLoginTenantId.Value; if (remoteJeecgUpdateTime.HasValue) localUser.JeecgUpdateTime = remoteJeecgUpdateTime; localUser.UpdateTime = DateTime.Now; var defaultSyncTenantId = _configuration.GetValue("JeecgIntegration:DefaultTenantId"); if (defaultSyncTenantId.HasValue && localUser.TenantId == defaultSyncTenantId.Value) { localUser.AccountType = AccountTypeEnum.SysAdmin; } var syncToLocal = _configuration.GetValue("JeecgIntegration:SyncUserProfileToLocal", true); if (syncToLocal) { await _dbContext.Updateable(localUser).UpdateColumns(u => new { u.RealName, u.NickName, u.Phone, u.Email, u.Avatar, u.Address, u.OfficePhone, u.JobNum, u.IdCardNum, u.Remark, u.Birthday, u.Sex, u.Status, u.TenantId, u.AccountType, u.JeecgUpdateTime, u.JeecgBizUserId, u.UpdateTime }).ExecuteCommandAsync(); } // 同步租户信息(以后端为准) if (hasTenantList) { await SyncTenantListAsync(tenantListElement); } // 同步Jeecg全量用户到本地 var syncAllUsersOnLogin = _configuration.GetValue("JeecgIntegration:SyncAllUsersOnJeecgLogin", true); var userListSyncHint = string.Empty; if (syncAllUsersOnLogin) { // 登录场景:SCADA 接口必须全量分页拉取,不能用 updatedAfter 增量提前结束,否则本地用户表长期不全 var (syncListOk, _) = await SyncAllJeecgUsersAsync(baseUrl, jeecgToken, localUser, allowScadaIncrementalQuery: false); if (!syncListOk) { userListSyncHint = "(提示:Jeecg 用户列表同步未拉到任何记录,用户管理可能为空;请检查 queryUser 接口、网络及程序运行目录下的 Admin.NET.db。)"; } localUser = await _dbContext.Queryable() .Includes(u => u.SysOrg) .ClearFilter() .Where(u => u.Id == localUser.Id) .FirstAsync() ?? localUser; } // SysAdmin菜单已在菜单服务中放行,不再写sys_tenant_menu,避免SQLite主键冲突 // 同步Jeecg权限明细到本地角色/菜单关系 await SyncJeecgPermissionToLocalAsync(baseUrl, jeecgToken, localUser); _currentUser = localUser; UserChanged?.Invoke(this, _currentUser); _sysCacheService.Set($"jeecg:token:{localUser.Id}", jeecgToken!, TimeSpan.FromHours(8)); return new LoginOutput { Success = true, Message = string.IsNullOrEmpty(userListSyncHint) ? "登录成功" : $"登录成功{userListSyncHint}", User = _currentUser, Token = await GenerateToken(localUser) }; } catch (Exception ex) { var friendlyError = ToFriendlyJeecgErrorMessage(ex.Message); return new LoginOutput { Success = false, Message = $"Jeecg登录失败:{friendlyError}" }; } } /// /// 同步 Jeecg 返回的租户列表到本地,字段以后端为准 /// private async Task SyncTenantListAsync(JsonElement tenantListElement) { if (tenantListElement.ValueKind != JsonValueKind.Array) return; var templateTenant = await _dbContext.Queryable().ClearFilter().OrderBy(t => t.Id).FirstAsync(); foreach (var tenantEl in tenantListElement.EnumerateArray()) { if (tenantEl.ValueKind != JsonValueKind.Object) continue; if (!tenantEl.TryGetProperty("id", out var idEl) || !idEl.TryGetInt64(out var tenantId) || tenantId <= 0) continue; var tenantName = tenantEl.TryGetProperty("name", out var nameEl) ? nameEl.GetString() : null; var companyLogo = tenantEl.TryGetProperty("companyLogo", out var logoEl) ? logoEl.GetString() : null; var status = ResolveTenantStatus(tenantEl); var exists = await _dbContext.Queryable().ClearFilter().Where(t => t.Id == tenantId).AnyAsync(); if (exists) { await _dbContext.Updateable() .SetColumns(t => t.Title == (string.IsNullOrWhiteSpace(tenantName) ? t.Title : tenantName)) .SetColumns(t => t.Logo == companyLogo) .SetColumns(t => t.Status == status) .SetColumns(t => t.UpdateTime == DateTime.Now) .Where(t => t.Id == tenantId) .ExecuteCommandAsync(); } else { var newTenant = new SysTenant { Id = tenantId, UserId = templateTenant?.UserId ?? _currentUser?.Id ?? 0, OrgId = templateTenant?.OrgId ?? _currentUser?.OrgId ?? 0, TenantType = templateTenant?.TenantType ?? TenantTypeEnum.Id, DbType = templateTenant?.DbType ?? DbType.Sqlite, Connection = templateTenant?.Connection, ConfigId = templateTenant?.ConfigId, SlaveConnections = templateTenant?.SlaveConnections, EnableReg = templateTenant?.EnableReg ?? YesNoEnum.N, RegWayId = templateTenant?.RegWayId, Logo = companyLogo, Title = string.IsNullOrWhiteSpace(tenantName) ? $"租户{tenantId}" : tenantName, ViceTitle = templateTenant?.ViceTitle, ViceDesc = templateTenant?.ViceDesc, Watermark = templateTenant?.Watermark, Copyright = templateTenant?.Copyright, Icp = templateTenant?.Icp, IcpUrl = templateTenant?.IcpUrl, OrderNo = templateTenant?.OrderNo ?? 100, Remark = templateTenant?.Remark, Status = status, CreateTime = DateTime.Now, UpdateTime = DateTime.Now }; await _dbContext.Insertable(newTenant).ExecuteCommandAsync(); } } } /// /// 清空本地用户/角色/租户数据,为Jeecg全量接管做准备 /// private async Task ResetLocalIdentityDataAsync() { await _dbContext.Ado.BeginTranAsync(); try { await _dbContext.Deleteable().ExecuteCommandAsync(); await _dbContext.Deleteable().ExecuteCommandAsync(); await _dbContext.Deleteable().ExecuteCommandAsync(); await _dbContext.Deleteable().ExecuteCommandAsync(); await _dbContext.Deleteable().ExecuteCommandAsync(); await _dbContext.Deleteable().ExecuteCommandAsync(); await _dbContext.Ado.CommitTranAsync(); // 清空 Jeecg 同步水位,否则仍按旧时间增量请求,首屏可能 0 条且误以为同步成功 try { new JeecgSyncStateStore().Save(new JeecgSyncState()); } catch { // 忽略状态文件写入失败 } } catch { await _dbContext.Ado.RollbackTranAsync(); throw; } } /// /// 完全采用Jeecg权限:同步当前用户角色与菜单权限明细到本地 /// private async Task SyncJeecgPermissionToLocalAsync(string baseUrl, string jeecgToken, SysUser localUser) { var permissionPath = _configuration.GetValue("JeecgIntegration:UserPermissionPath") ?? "/sys/permission/getUserPermissionByToken"; var requestUrl = $"{baseUrl}{permissionPath}"; using var req = new HttpRequestMessage(HttpMethod.Get, requestUrl); req.Headers.TryAddWithoutValidation("X-Access-Token", jeecgToken); using var resp = await _httpClient.SendAsync(req); if (!resp.IsSuccessStatusCode) return; var json = await resp.Content.ReadAsStringAsync(); using var doc = JsonDocument.Parse(json); var root = doc.RootElement; if (!(root.TryGetProperty("success", out var successEl) && successEl.GetBoolean())) return; if (!root.TryGetProperty("result", out var resultEl) || resultEl.ValueKind != JsonValueKind.Object) return; var permissionCodes = new HashSet(StringComparer.OrdinalIgnoreCase); var routeKeys = new HashSet(StringComparer.OrdinalIgnoreCase); if (resultEl.TryGetProperty("codeList", out var codeListEl) && codeListEl.ValueKind == JsonValueKind.Array) { foreach (var item in codeListEl.EnumerateArray()) { if (item.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(item.GetString())) { permissionCodes.Add(item.GetString()!); } } } if (resultEl.TryGetProperty("menu", out var menuEl)) { CollectJeecgMenuKeys(menuEl, routeKeys, permissionCodes); } var roleCode = $"jeecg_sync_{localUser.Account}".ToLowerInvariant(); var role = await _dbContext.Queryable().ClearFilter().Where(r => r.Code == roleCode).FirstAsync(); if (role == null) { role = new SysRole { Name = $"Jeecg同步角色_{localUser.Account}", Code = roleCode, TenantId = localUser.TenantId ?? 0, DataScope = DataScopeEnum.All, Status = StatusEnum.Enable, OrderNo = 1, Remark = "Jeecg同步自动生成", CreateTime = DateTime.Now, UpdateTime = DateTime.Now }; role.Id = await TakeNextSysRoleIdAsync(); await _dbContext.Insertable(role).ExecuteCommandAsync(); role = await _dbContext.Queryable().ClearFilter().Where(r => r.Code == roleCode).FirstAsync(); } if (role == null) return; await _dbContext.Deleteable().Where(x => x.UserId == localUser.Id).ExecuteCommandAsync(); var maxUserRoleId = await _dbContext.Queryable().ClearFilter().MaxAsync(x => (long?)x.Id) ?? 0; await _dbContext.Insertable(new SysUserRole { Id = maxUserRoleId + 1, UserId = localUser.Id, RoleId = role.Id }).ExecuteCommandAsync(); await _dbContext.Deleteable().Where(x => x.RoleId == role.Id).ExecuteCommandAsync(); var localMenus = await _dbContext.Queryable().ClearFilter() .Where(m => m.Type != MenuTypeEnum.Btn && m.Status == StatusEnum.Enable) .ToListAsync(); var defaultTenantId = _configuration.GetValue("JeecgIntegration:DefaultTenantId"); var useAllMenus = defaultTenantId.HasValue && localUser.TenantId == defaultTenantId.Value; var matchedMenuIds = useAllMenus ? localMenus.Select(m => m.Id).Distinct().ToList() : localMenus.Where(m => IsLocalMenuMatchedByJeecg(m, routeKeys, permissionCodes)) .Select(m => m.Id) .Distinct() .ToList(); if (matchedMenuIds.Count == 0) return; var maxRoleMenuId = await _dbContext.Queryable().ClearFilter().MaxAsync(x => (long?)x.Id) ?? 0; foreach (var menuId in matchedMenuIds.Distinct()) { await _dbContext.Insertable(new SysRoleMenu { Id = ++maxRoleMenuId, RoleId = role.Id, MenuId = menuId }).ExecuteCommandAsync(); } } /// /// 递归提取Jeecg菜单中的路径、名称、权限编码 /// private static void CollectJeecgMenuKeys(JsonElement node, HashSet routeKeys, HashSet permissionCodes) { if (node.ValueKind == JsonValueKind.Array) { foreach (var child in node.EnumerateArray()) { CollectJeecgMenuKeys(child, routeKeys, permissionCodes); } return; } if (node.ValueKind != JsonValueKind.Object) return; AddIfString(node, "path", routeKeys); AddIfString(node, "url", routeKeys); AddIfString(node, "name", routeKeys); AddIfString(node, "component", routeKeys); AddIfString(node, "title", routeKeys); AddIfString(node, "perms", permissionCodes); AddIfString(node, "permission", permissionCodes); if (node.TryGetProperty("meta", out var metaEl) && metaEl.ValueKind == JsonValueKind.Object) { AddIfString(metaEl, "title", routeKeys); AddIfString(metaEl, "component", routeKeys); if (metaEl.TryGetProperty("permissionList", out var permissionListEl) && permissionListEl.ValueKind == JsonValueKind.Array) { foreach (var p in permissionListEl.EnumerateArray()) { if (p.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(p.GetString())) { permissionCodes.Add(p.GetString()!); } } } } if (node.TryGetProperty("children", out var childrenEl)) { CollectJeecgMenuKeys(childrenEl, routeKeys, permissionCodes); } } private static bool IsLocalMenuMatchedByJeecg(SysMenu menu, HashSet routeKeys, HashSet permissionCodes) { var keys = new[] { Normalize(menu.Path), Normalize(menu.Name), Normalize(menu.Component), Normalize(menu.Title) }; if (!string.IsNullOrWhiteSpace(menu.Permission) && permissionCodes.Contains(menu.Permission.Trim())) { return true; } return keys.Any(k => !string.IsNullOrWhiteSpace(k) && routeKeys.Contains(k)); } private static void AddIfString(JsonElement obj, string propertyName, HashSet target) { if (obj.TryGetProperty(propertyName, out var valueEl) && valueEl.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(valueEl.GetString())) { target.Add(Normalize(valueEl.GetString())); } } private static string Normalize(string? value) { return (value ?? string.Empty).Trim().Trim('/').ToLowerInvariant(); } private static string Normalize(JsonElement valueEl) { return Normalize(valueEl.GetString()); } /// /// 分配下一个本地用户主键(sys_user 非自增,Jeecg 建档时必须显式赋值) /// private async Task TakeNextSysUserIdAsync() { var maxId = await _dbContext.Queryable().ClearFilter().MaxAsync(x => (long?)x.Id) ?? 0; return maxId + 1; } /// /// 分配下一个本地角色主键(sys_role 非自增,多账号 Jeecg 同步各建角色时须避免 Id=0 重复) /// private async Task TakeNextSysRoleIdAsync() { var maxId = await _dbContext.Queryable().ClearFilter().MaxAsync(x => (long?)x.Id) ?? 0; return maxId + 1; } /// /// 安全解析Jeecg租户状态,避免null或字符串导致异常 /// private static StatusEnum ResolveTenantStatus(JsonElement tenantEl) { if (!tenantEl.TryGetProperty("status", out var statusEl)) { return StatusEnum.Disable; } if (statusEl.ValueKind == JsonValueKind.Number && statusEl.TryGetInt32(out var numStatus)) { return numStatus == 1 ? StatusEnum.Enable : StatusEnum.Disable; } if (statusEl.ValueKind == JsonValueKind.String && int.TryParse(statusEl.GetString(), out var strStatus)) { return strStatus == 1 ? StatusEnum.Enable : StatusEnum.Disable; } return StatusEnum.Disable; } /// /// 有 Token 时附加 X-Access-Token(SCADA 免登录接口可不传) /// private static void AddJeecgAccessTokenIfPresent(HttpRequestMessage request, string? jeecgToken) { if (!string.IsNullOrWhiteSpace(jeecgToken)) { request.Headers.TryAddWithoutValidation("X-Access-Token", jeecgToken); } } /// /// 从接口返回的用户数组中累计最大 updateTime/createTime(转 UTC),用于推进 SCADA 同步游标 /// private static void AccumulateMaxJeecgSyncCursorUtc(IEnumerable records, ref DateTime? maxUtc) { foreach (var el in records) { DateTime? tUpdate = el.TryGetProperty("updateTime", out var ue) ? TryParseJeecgRemoteDateTime(ue) : null; DateTime? tCreate = el.TryGetProperty("createTime", out var ce) ? TryParseJeecgRemoteDateTime(ce) : null; DateTime? rowBest; if (tUpdate.HasValue && tCreate.HasValue) { rowBest = tUpdate.Value >= tCreate.Value ? tUpdate : tCreate; } else { rowBest = tUpdate ?? tCreate; } if (!rowBest.HasValue) { continue; } var asUtc = rowBest.Value.Kind == DateTimeKind.Utc ? rowBest.Value : rowBest.Value.ToUniversalTime(); if (!maxUtc.HasValue || asUtc > maxUtc.Value) { maxUtc = asUtc; } } } /// /// 将一批 Jeecg/SCADA 用户 JSON 写入本地;若接口返回 password/salt(如 SCADA 同步),则写入本地用于与 Jeecg 一致的登录校验。 /// private async Task ProcessJeecgUserRecordsAsync( IReadOnlyList records, SysUser templateUser, SysUser currentUser, long? defaultTenantId, bool skipUnchanged) { foreach (var userEl in records) { var remoteUser = ParseJeecgUser(userEl); if (string.IsNullOrWhiteSpace(remoteUser.Username)) { continue; } // 无匹配行时 FirstAsync 会抛错,导致整批同步中断;用 Take(1) 安全取首条 var existing = (await _dbContext.Queryable() .ClearFilter() .Where(u => u.Account == remoteUser.Username) .Take(1) .ToListAsync()).FirstOrDefault(); if (skipUnchanged && existing != null && existing.JeecgUpdateTime.HasValue && remoteUser.JeecgSourceUpdateTime.HasValue && Math.Abs((existing.JeecgUpdateTime.Value - remoteUser.JeecgSourceUpdateTime.Value).TotalSeconds) < 2) { continue; } var mappedTenantId = remoteUser.TenantId ?? defaultTenantId ?? templateUser.TenantId ?? currentUser.TenantId ?? 1300000000001; var mappedAccountType = mappedTenantId == (defaultTenantId ?? 0) ? AccountTypeEnum.SysAdmin : AccountTypeEnum.NormalUser; if (existing == null) { var createAt = remoteUser.JeecgSourceCreateTime ?? DateTime.Now; var newUser = new SysUser { Account = remoteUser.Username!, RealName = string.IsNullOrWhiteSpace(remoteUser.RealName) ? remoteUser.Username! : remoteUser.RealName!, NickName = remoteUser.NickName, Password = !string.IsNullOrWhiteSpace(remoteUser.PasswordHex) ? remoteUser.PasswordHex! : CryptogramUtil.Encrypt(Guid.NewGuid().ToString("N")), JeecgPasswordSalt = remoteUser.Salt, Sex = remoteUser.Sex ?? GenderEnum.Unknown, Birthday = remoteUser.Birthday, Phone = remoteUser.Phone, OfficePhone = remoteUser.OfficePhone, Email = remoteUser.Email, Avatar = remoteUser.Avatar, Address = remoteUser.Address, JobNum = remoteUser.JobNum, IdCardNum = remoteUser.IdCardNum, Remark = remoteUser.Remark, PosTitle = remoteUser.PosTitle, Status = remoteUser.Status ?? StatusEnum.Enable, TenantId = mappedTenantId, AccountType = mappedAccountType, OrgId = templateUser.OrgId, PosId = templateUser.PosId, JeecgUpdateTime = remoteUser.JeecgSourceUpdateTime, JeecgBizUserId = remoteUser.JeecgBizUserId, JeecgOrgCode = remoteUser.OrgCode, JeecgDepartIds = remoteUser.DepartIds, CreateTime = createAt, UpdateTime = DateTime.Now }; newUser.Id = await TakeNextSysUserIdAsync(); await _dbContext.Insertable(newUser).ExecuteCommandAsync(); } else { existing.RealName = string.IsNullOrWhiteSpace(remoteUser.RealName) ? existing.RealName : remoteUser.RealName!; existing.NickName = remoteUser.NickName; existing.Phone = remoteUser.Phone; existing.Email = remoteUser.Email; existing.Avatar = remoteUser.Avatar; existing.Address = remoteUser.Address; existing.OfficePhone = remoteUser.OfficePhone; existing.JobNum = remoteUser.JobNum; existing.IdCardNum = remoteUser.IdCardNum; existing.Remark = remoteUser.Remark; existing.PosTitle = remoteUser.PosTitle; if (remoteUser.Birthday.HasValue) { existing.Birthday = remoteUser.Birthday; } if (remoteUser.Sex.HasValue) { existing.Sex = remoteUser.Sex.Value; } if (remoteUser.Status.HasValue) { existing.Status = remoteUser.Status.Value; } existing.TenantId = mappedTenantId; existing.AccountType = mappedAccountType; existing.JeecgUpdateTime = remoteUser.JeecgSourceUpdateTime ?? existing.JeecgUpdateTime; if (!string.IsNullOrWhiteSpace(remoteUser.JeecgBizUserId)) { existing.JeecgBizUserId = remoteUser.JeecgBizUserId; } existing.JeecgOrgCode = remoteUser.OrgCode; existing.JeecgDepartIds = remoteUser.DepartIds; existing.UpdateTime = DateTime.Now; if (!string.IsNullOrWhiteSpace(remoteUser.PasswordHex)) { existing.Password = remoteUser.PasswordHex!; } if (!string.IsNullOrWhiteSpace(remoteUser.Salt)) { existing.JeecgPasswordSalt = remoteUser.Salt; } await _dbContext.Updateable(existing).UpdateColumns(u => new { u.RealName, u.NickName, u.Phone, u.Email, u.Avatar, u.Address, u.OfficePhone, u.JobNum, u.IdCardNum, u.Remark, u.PosTitle, u.Birthday, u.Sex, u.Status, u.TenantId, u.AccountType, u.JeecgUpdateTime, u.JeecgBizUserId, u.JeecgOrgCode, u.JeecgDepartIds, u.UpdateTime, u.Password, u.JeecgPasswordSalt }).ExecuteCommandAsync(); } } } /// /// 同步 Jeecg 用户:标准列表为 result.records 分页;SCADA 为 result 数组分页 + 可选 updatedAfter(免登录时可不传 Token)。SCADA 若返回 password/salt 则写入本地。 /// /// false 时(如登录后首拉)SCADA 不带 updatedAfter,始终按页拉全量;true 时(如定时后台)可按水位做增量。 /// 是否完成有效同步;ApiRecordRows 为接口返回的用户记录条数(分页累计),为 0 时表示未拉到数据。 private async Task<(bool Success, int ApiRecordRows)> SyncAllJeecgUsersAsync(string baseUrl, string jeecgToken, SysUser currentUser, bool allowScadaIncrementalQuery = true) { var userListPath = _configuration.GetValue("JeecgIntegration:UserListPath") ?? "/sys/user/list"; var templateCandidates = await _dbContext.Queryable().ClearFilter().OrderBy(u => u.Id).Take(1).ToListAsync(); var templateUser = templateCandidates.FirstOrDefault() ?? currentUser; var defaultTenantId = _configuration.GetValue("JeecgIntegration:DefaultTenantId"); var skipUnchanged = _configuration.GetValue("JeecgIntegration:UserSyncSkipUnchanged", true); var useTimeQuery = _configuration.GetValue("JeecgIntegration:UserListUseUpdateTimeQuery", false); var overlapMinutes = _configuration.GetValue("JeecgIntegration:IncrementalSyncOverlapMinutes", 2); var isScadaStylePath = userListPath.Contains("scada", StringComparison.OrdinalIgnoreCase); var scadaUseUpdatedAfter = _configuration.GetValue("JeecgIntegration:ScadaUseUpdatedAfter", true); var scadaPageSize = Math.Clamp(_configuration.GetValue("JeecgIntegration:ScadaUserPageSize", 500), 1, 1000); var scadaIncludeDetail = _configuration.GetValue("JeecgIntegration:ScadaUserIncludeDetail", false); var stateStore = new JeecgSyncStateStore(); var syncState = stateStore.Load(); const int standardPageSize = 200; // pass 0:增量;若整轮 0 条则 pass 1 全量(避免清空库后旧水位导致拉不到用户) for (var pass = 0; pass < 2; pass++) { var useIncremental = pass == 0 && syncState.LastUserListSyncUtc.HasValue && ( (isScadaStylePath && scadaUseUpdatedAfter && allowScadaIncrementalQuery) || (!isScadaStylePath && useTimeQuery)); DateTime? incrementalCursorLocal = null; if (useIncremental) { incrementalCursorLocal = syncState.LastUserListSyncUtc!.Value .AddMinutes(-Math.Max(0, overlapMinutes)) .ToLocalTime(); } var httpOk = true; var rowsThisPass = 0; DateTime? maxCursorUtcFromData = null; if (isScadaStylePath) { var pageNo = 1; while (true) { var listUrl = $"{baseUrl}{userListPath}?pageNo={pageNo.ToString(CultureInfo.InvariantCulture)}&pageSize={scadaPageSize.ToString(CultureInfo.InvariantCulture)}&includeDetail={(scadaIncludeDetail ? "true" : "false")}"; if (incrementalCursorLocal.HasValue) { var afterStr = incrementalCursorLocal.Value.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture); listUrl += $"&updatedAfter={Uri.EscapeDataString(afterStr)}"; } using var req = new HttpRequestMessage(HttpMethod.Get, listUrl); AddJeecgAccessTokenIfPresent(req, jeecgToken); using var resp = await _httpClient.SendAsync(req); if (!resp.IsSuccessStatusCode) { httpOk = false; break; } var json = await resp.Content.ReadAsStringAsync(); using var doc = JsonDocument.Parse(json); var root = doc.RootElement; if (!(root.TryGetProperty("success", out var successEl) && successEl.GetBoolean())) { httpOk = false; break; } if (!root.TryGetProperty("result", out var resultEl)) { httpOk = false; break; } List records; if (resultEl.ValueKind == JsonValueKind.Array) { records = resultEl.EnumerateArray().ToList(); } else if (resultEl.ValueKind == JsonValueKind.Object && resultEl.TryGetProperty("records", out var recordsEl) && recordsEl.ValueKind == JsonValueKind.Array) { records = recordsEl.EnumerateArray().ToList(); } else { httpOk = false; break; } if (records.Count == 0) { break; } rowsThisPass += records.Count; AccumulateMaxJeecgSyncCursorUtc(records, ref maxCursorUtcFromData); await ProcessJeecgUserRecordsAsync(records, templateUser, currentUser, defaultTenantId, skipUnchanged); if (records.Count < scadaPageSize) { break; } pageNo++; } if (!httpOk) { return (false, rowsThisPass); } if (useIncremental && rowsThisPass == 0) { continue; } // 接口 success 但本批 0 条:不能当作同步成功,否则界面误导且水位仍会推进 if (rowsThisPass == 0) { return (false, 0); } syncState.LastUserListSyncUtc = maxCursorUtcFromData ?? DateTime.UtcNow; stateStore.Save(syncState); return (true, rowsThisPass); } var stdPageNo = 1; while (true) { var listUrl = $"{baseUrl}{userListPath}?pageNo={stdPageNo}&pageSize={standardPageSize}"; if (incrementalCursorLocal.HasValue) { var beginStr = incrementalCursorLocal.Value.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture); listUrl += $"&updateTime_begin={Uri.EscapeDataString(beginStr)}"; } using var req = new HttpRequestMessage(HttpMethod.Get, listUrl); AddJeecgAccessTokenIfPresent(req, jeecgToken); using var resp = await _httpClient.SendAsync(req); if (!resp.IsSuccessStatusCode) { httpOk = false; break; } var json = await resp.Content.ReadAsStringAsync(); using var doc = JsonDocument.Parse(json); var root = doc.RootElement; if (!(root.TryGetProperty("success", out var successEl) && successEl.GetBoolean())) { httpOk = false; break; } if (!root.TryGetProperty("result", out var resultEl) || resultEl.ValueKind != JsonValueKind.Object) { httpOk = false; break; } if (!resultEl.TryGetProperty("records", out var recordsEl) || recordsEl.ValueKind != JsonValueKind.Array) { httpOk = false; break; } var records = recordsEl.EnumerateArray().ToList(); if (records.Count == 0) { break; } rowsThisPass += records.Count; await ProcessJeecgUserRecordsAsync(records, templateUser, currentUser, defaultTenantId, skipUnchanged); long total = 0; if (resultEl.TryGetProperty("total", out var totalEl)) { if (totalEl.TryGetInt64(out var tl)) { total = tl; } else if (totalEl.TryGetInt32(out var ti)) { total = ti; } } if (total <= (long)stdPageNo * standardPageSize) { break; } stdPageNo++; } if (!httpOk) { return (false, rowsThisPass); } if (useIncremental && rowsThisPass == 0) { continue; } if (rowsThisPass == 0) { return (false, 0); } syncState.LastUserListSyncUtc = DateTime.UtcNow; stateStore.Save(syncState); return (true, rowsThisPass); } return (false, 0); } /// /// 登录页一键同步:依赖 SCADA 免登录 queryUser(无需 Token);空库时用配置租户/机构占位写入新用户。 /// public async Task<(bool Success, string Message)> SyncJeecgUsersToLocalFromLoginScreenAsync(CancellationToken cancellationToken = default) { if (!_configuration.GetValue("JeecgIntegration:Enabled", false)) { return (false, "未启用 Jeecg 集成(JeecgIntegration:Enabled)。"); } var baseUrl = _configuration.GetValue("JeecgIntegration:BaseUrl")?.TrimEnd('/'); if (string.IsNullOrWhiteSpace(baseUrl)) { return (false, "未配置 JeecgIntegration:BaseUrl。"); } var userListPath = _configuration.GetValue("JeecgIntegration:UserListPath") ?? string.Empty; if (!userListPath.Contains("scada", StringComparison.OrdinalIgnoreCase)) { return (false, "一键同步需使用免登录 SCADA 接口。请将 UserListPath 配置为 /sys/user/scada/queryUser,或先登录后由系统内定时同步。"); } try { cancellationToken.ThrowIfCancellationRequested(); var (ok, apiRows, _) = await SyncScadaUsersToJeecgMirrorTableAsync(baseUrl, userListPath, cancellationToken); return ok ? (true, $"已从 Jeecg 拉取用户并写入本地同构表 jeecg_sys_user(接口累计 {apiRows} 条)。") : (false, apiRows == 0 ? "同步未完成:接口未返回任何用户记录。请检查 Jeecg queryUser、BaseUrl/UserListPath、网络与白名单;若用 Navicat 查看数据,请打开程序运行目录(如 bin\\Debug\\...\\win-x64)下的 Admin.NET.db,而非仅源码目录下的文件。" : "同步未完成:请检查 Jeecg 地址、网络,或查看接口是否返回 success=true。"); } catch (OperationCanceledException) { throw; } catch (Exception ex) { return (false, $"同步异常:{ex.Message}"); } } /// /// 登录页按钮专用:将 SCADA 接口用户同步到 Jeecg 同构表(jeecg_sys_user)。 /// private async Task<(bool Success, int ApiRecordRows, int ChangedRows)> SyncScadaUsersToJeecgMirrorTableAsync(string baseUrl, string userListPath, CancellationToken cancellationToken) { var scadaPageSize = Math.Clamp(_configuration.GetValue("JeecgIntegration:ScadaUserPageSize", 500), 1, 1000); var scadaIncludeDetail = _configuration.GetValue("JeecgIntegration:ScadaUserIncludeDetail", false); var pageNo = 1; var totalRows = 0; var changedRows = 0; var remoteIds = new HashSet(StringComparer.OrdinalIgnoreCase); var remoteUsernames = new HashSet(StringComparer.OrdinalIgnoreCase); while (true) { cancellationToken.ThrowIfCancellationRequested(); var listUrl = $"{baseUrl}{userListPath}?pageNo={pageNo.ToString(CultureInfo.InvariantCulture)}&pageSize={scadaPageSize.ToString(CultureInfo.InvariantCulture)}&includeDetail={(scadaIncludeDetail ? "true" : "false")}"; using var req = new HttpRequestMessage(HttpMethod.Get, listUrl); using var resp = await _httpClient.SendAsync(req, cancellationToken); if (!resp.IsSuccessStatusCode) { return (false, totalRows, changedRows); } var json = await resp.Content.ReadAsStringAsync(cancellationToken); using var doc = JsonDocument.Parse(json); var root = doc.RootElement; if (!(root.TryGetProperty("success", out var successEl) && successEl.GetBoolean())) { return (false, totalRows, changedRows); } if (!root.TryGetProperty("result", out var resultEl)) { return (false, totalRows, changedRows); } List records; if (resultEl.ValueKind == JsonValueKind.Array) { records = resultEl.EnumerateArray().ToList(); } else if (resultEl.ValueKind == JsonValueKind.Object && resultEl.TryGetProperty("records", out var recordsEl) && recordsEl.ValueKind == JsonValueKind.Array) { records = recordsEl.EnumerateArray().ToList(); } else { return (false, totalRows, changedRows); } if (records.Count == 0) { break; } CollectRemoteUserKeys(records, remoteIds, remoteUsernames); totalRows += records.Count; changedRows += await UpsertJeecgMirrorUsersAsync(records); long total = 0; if (resultEl.ValueKind == JsonValueKind.Object && resultEl.TryGetProperty("total", out var totalEl)) { if (totalEl.TryGetInt64(out var tl)) { total = tl; } else if (totalEl.TryGetInt32(out var ti)) { total = ti; } } if (total > 0) { if (total <= (long)pageNo * scadaPageSize) { break; } } else if (records.Count < scadaPageSize) { break; } pageNo++; } var deletedRows = await DeleteMissingJeecgMirrorUsersAsync(remoteIds, remoteUsernames); changedRows += deletedRows; return (true, totalRows, changedRows); } /// /// 收集远端用户主键集合,用于全量同步后的本地删除对齐。 /// private static void CollectRemoteUserKeys( IReadOnlyList records, ISet remoteIds, ISet remoteUsernames) { foreach (var userEl in records) { if (userEl.ValueKind != JsonValueKind.Object) { continue; } if (userEl.TryGetProperty("id", out var idEl)) { var idValue = idEl.ValueKind switch { JsonValueKind.String => idEl.GetString(), JsonValueKind.Number => idEl.GetRawText(), _ => null }; if (!string.IsNullOrWhiteSpace(idValue)) { remoteIds.Add(idValue); } } if (userEl.TryGetProperty("username", out var usernameEl) && usernameEl.ValueKind == JsonValueKind.String) { var username = usernameEl.GetString(); if (!string.IsNullOrWhiteSpace(username)) { remoteUsernames.Add(username); } } } } /// /// 删除本地 jeecg_sys_user 中已被后端删除的账号,保持镜像一致。 /// private async Task DeleteMissingJeecgMirrorUsersAsync(ISet remoteIds, ISet remoteUsernames) { var localRows = await _dbContext.Queryable() .ClearFilter() .Select(x => new { x.Id, x.Username }) .ToListAsync(); if (localRows.Count == 0) { return 0; } var staleIds = localRows .Where(x => !string.IsNullOrWhiteSpace(x.Id) && !remoteIds.Contains(x.Id) && (string.IsNullOrWhiteSpace(x.Username) || !remoteUsernames.Contains(x.Username))) .Select(x => x.Id!) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); if (staleIds.Count == 0) { return 0; } var deletedRows = await _dbContext.Deleteable() .In(it => it.Id, staleIds) .ExecuteCommandAsync(); _logger.Information($"Jeecg镜像删除同步: 表=jeecg_sys_user, 删除行数={deletedRows}"); return deletedRows; } /// /// 写入 Jeecg 同构用户表 jeecg_sys_user(按 id 或 username 做幂等更新)。 /// private async Task UpsertJeecgMirrorUsersAsync(IReadOnlyList records) { var changedRows = 0; foreach (var userEl in records) { var remoteUser = ParseJeecgUser(userEl); if (string.IsNullOrWhiteSpace(remoteUser.Username)) { continue; } var remoteId = remoteUser.JeecgBizUserId; if (string.IsNullOrWhiteSpace(remoteId) && userEl.TryGetProperty("id", out var idEl)) { if (idEl.ValueKind == JsonValueKind.String) { remoteId = idEl.GetString(); } else if (idEl.ValueKind == JsonValueKind.Number) { remoteId = idEl.GetRawText(); } } remoteId ??= remoteUser.Username!; var existing = (await _dbContext.Queryable() .ClearFilter() .Where(x => x.Id == remoteId || x.Username == remoteUser.Username) .Take(1) .ToListAsync()).FirstOrDefault(); var isUpdate = existing != null; var oldAccount = existing?.Username; var oldRealname = existing?.Realname; var oldPhone = existing?.Phone; var oldUpdateTime = existing?.UpdateTime?.ToString("yyyy-MM-dd HH:mm:ss"); if (existing == null) { existing = new JeecgSysUser { Id = remoteId }; } existing.Username = remoteUser.Username; existing.Realname = remoteUser.RealName; existing.Password = remoteUser.PasswordHex; existing.Salt = remoteUser.Salt; existing.Birthday = remoteUser.Birthday; existing.Sex = remoteUser.Sex.HasValue ? (int?)remoteUser.Sex.Value : null; existing.Email = remoteUser.Email; existing.Phone = remoteUser.Phone; existing.OrgCode = remoteUser.OrgCode; existing.LoginTenantId = remoteUser.TenantId.HasValue ? (int?)remoteUser.TenantId.Value : null; existing.Status = remoteUser.Status.HasValue ? (int?)remoteUser.Status.Value : null; existing.WorkNo = remoteUser.JobNum; existing.Telephone = remoteUser.OfficePhone; existing.CreateTime = remoteUser.JeecgSourceCreateTime; existing.UpdateTime = remoteUser.JeecgSourceUpdateTime; existing.DepartIds = remoteUser.DepartIds; existing.ClientId = userEl.TryGetProperty("clientId", out var clientIdEl) ? clientIdEl.GetString() : existing.ClientId; existing.BpmStatus = userEl.TryGetProperty("bpmStatus", out var bpmStatusEl) ? bpmStatusEl.GetString() : existing.BpmStatus; existing.Sign = userEl.TryGetProperty("sign", out var signEl) ? signEl.GetString() : existing.Sign; existing.SignEnable = userEl.TryGetProperty("signEnable", out var signEnableEl) && signEnableEl.TryGetInt32(out var signEnable) ? signEnable : existing.SignEnable; existing.MainDepPostId = userEl.TryGetProperty("mainDepPostId", out var mainDepPostIdEl) ? mainDepPostIdEl.GetString() : existing.MainDepPostId; existing.PositionType = userEl.TryGetProperty("positionType", out var positionTypeEl) ? positionTypeEl.GetString() : existing.PositionType; existing.LastPwdUpdateTime = userEl.TryGetProperty("lastPwdUpdateTime", out var lastPwdUpdateEl) ? TryParseJeecgRemoteDateTime(lastPwdUpdateEl) : existing.LastPwdUpdateTime; existing.Sort = userEl.TryGetProperty("sort", out var sortEl) && sortEl.TryGetInt32(out var sort) ? sort : existing.Sort; existing.IzHideContact = userEl.TryGetProperty("izHideContact", out var hideContactEl) ? hideContactEl.GetString() : existing.IzHideContact; var newAccount = existing.Username; var newRealname = existing.Realname; var newPhone = existing.Phone; var newUpdateTime = existing.UpdateTime?.ToString("yyyy-MM-dd HH:mm:ss"); var hasChanged = !isUpdate || !string.Equals(oldAccount, newAccount, StringComparison.Ordinal) || !string.Equals(oldRealname, newRealname, StringComparison.Ordinal) || !string.Equals(oldPhone, newPhone, StringComparison.Ordinal) || !string.Equals(oldUpdateTime, newUpdateTime, StringComparison.Ordinal); if (!hasChanged) { continue; } if (isUpdate) { await _dbContext.Updateable(existing).ExecuteCommandAsync(); } else { await _dbContext.Insertable(existing).ExecuteCommandAsync(); } changedRows++; _logger.Information( $"Jeecg镜像写库明细[{(isUpdate ? "UPDATE" : "INSERT")}] 表=jeecg_sys_user, " + $"账号: {ToLogValue(oldAccount)} -> {ToLogValue(newAccount)}, " + $"姓名: {ToLogValue(oldRealname)} -> {ToLogValue(newRealname)}, " + $"手机: {ToLogValue(oldPhone)} -> {ToLogValue(newPhone)}, " + $"更新时间: {ToLogValue(oldUpdateTime)} -> {ToLogValue(newUpdateTime)}"); } return changedRows; } private static string ToLogValue(string? value) { return string.IsNullOrWhiteSpace(value) ? "" : value; } /// /// 后台使用缓存 Token 同步用户(不弹窗) /// public async Task TryBackgroundSyncJeecgUsersAsync(CancellationToken cancellationToken = default) { if (!_configuration.GetValue("JeecgIntegration:Enabled", false)) { return false; } if (!_configuration.GetValue("JeecgIntegration:SyncAllUsersOnJeecgLogin", false)) { return false; } var userListPath = _configuration.GetValue("JeecgIntegration:UserListPath") ?? string.Empty; var isScadaUserPath = userListPath.Contains("scada", StringComparison.OrdinalIgnoreCase); var user = _currentUser; if (user == null && !isScadaUserPath) { return false; } var token = user == null ? string.Empty : _sysCacheService.Get($"jeecg:token:{user.Id}"); if (string.IsNullOrWhiteSpace(token) && !isScadaUserPath) { return false; } var baseUrl = _configuration.GetValue("JeecgIntegration:BaseUrl")?.TrimEnd('/'); if (string.IsNullOrWhiteSpace(baseUrl)) { return false; } cancellationToken.ThrowIfCancellationRequested(); try { (bool ok, int rows, int changedRows) result; if (isScadaUserPath) { // 实时接收链路统一写入 jeecg_sys_user,避免写到 sys_user 导致展示与同步表不一致 result = await SyncScadaUsersToJeecgMirrorTableAsync(baseUrl, userListPath, cancellationToken); } else { var standardResult = await SyncAllJeecgUsersAsync(baseUrl, token ?? string.Empty, user!); result = (standardResult.Success, standardResult.ApiRecordRows, standardResult.ApiRecordRows); } var (ok, rows, changedRows) = result; if (ok && changedRows > 0) { _eventAggregator.GetEvent().Publish(rows); } return ok; } catch (OperationCanceledException) { throw; } catch { return false; } } /// /// 解析 Jeecg userInfo.id(字符串或数字雪花),超出 long 范围则返回 null /// private static long? TryParseJeecgSnowflakeUserIdFromJson(JsonElement idEl) { switch (idEl.ValueKind) { case JsonValueKind.Number: if (idEl.TryGetInt64(out var n) && n > 0) { return n; } var raw = idEl.GetRawText(); if (long.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var r) && r > 0) { return r; } return null; case JsonValueKind.String: if (long.TryParse(idEl.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var s) && s > 0) { return s; } return null; default: return null; } } /// /// 将 sys_user 主键从 oldId 改为 newId(SQLite),并同步常见子表 user_id /// private async Task TryMigrateSysUserPrimaryKeyToJeecgIdAsync(long oldId, long newId) { if (oldId == newId) { return; } await _dbContext.Ado.BeginTranAsync(); try { await _dbContext.Ado.ExecuteCommandAsync("PRAGMA foreign_keys = OFF"); var fkUpdates = new (string Table, string Column)[] { ("sys_user_role", "user_id"), ("sys_user_menu", "user_id"), ("sys_user_ext_org", "user_id"), ("sys_user_ldap", "user_id"), ("sys_user_config_data", "user_id"), ("sys_online_user", "user_id"), ("sys_notice_user", "user_id"), ("sys_schedule", "user_id"), ("sys_tenant", "user_id"), }; foreach (var (table, column) in fkUpdates) { await _dbContext.Ado.ExecuteCommandAsync( $"UPDATE {table} SET {column} = @newId WHERE {column} = @oldId", new { newId, oldId }); } await _dbContext.Ado.ExecuteCommandAsync( "UPDATE sys_user SET id = @newId WHERE id = @oldId", new { newId, oldId }); await _dbContext.Ado.ExecuteCommandAsync("PRAGMA foreign_keys = ON"); await _dbContext.Ado.CommitTranAsync(); } catch { await _dbContext.Ado.RollbackTranAsync(); try { await _dbContext.Ado.ExecuteCommandAsync("PRAGMA foreign_keys = ON"); } catch { // 忽略恢复失败 } throw; } } private static DateTime? TryParseJeecgRemoteDateTime(JsonElement el) { if (el.ValueKind == JsonValueKind.String) { if (DateTime.TryParse(el.GetString(), CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out var d1)) { return d1; } if (DateTime.TryParse(el.GetString(), CultureInfo.GetCultureInfo("zh-CN"), DateTimeStyles.AssumeLocal, out var d2)) { return d2; } return null; } if (el.ValueKind == JsonValueKind.Number && el.TryGetInt64(out var n)) { if (n > 1_000_000_000_000L) { return DateTimeOffset.FromUnixTimeMilliseconds(n).LocalDateTime; } if (n > 1_000_000_000L) { return DateTimeOffset.FromUnixTimeSeconds(n).LocalDateTime; } } return null; } private static JeecgUserProfile ParseJeecgUser(JsonElement userInfoEl) { var profile = new JeecgUserProfile(); if (userInfoEl.ValueKind != JsonValueKind.Object) return profile; if (userInfoEl.TryGetProperty("username", out var usernameEl)) profile.Username = usernameEl.GetString(); if (string.IsNullOrWhiteSpace(profile.Username) && userInfoEl.TryGetProperty("account", out var accountEl)) { profile.Username = accountEl.ValueKind == JsonValueKind.String ? accountEl.GetString() : null; } if (userInfoEl.TryGetProperty("realname", out var realnameEl)) profile.RealName = realnameEl.GetString(); if (userInfoEl.TryGetProperty("nickname", out var nicknameEl)) profile.NickName = nicknameEl.GetString(); if (userInfoEl.TryGetProperty("phone", out var phoneEl)) profile.Phone = phoneEl.GetString(); if (userInfoEl.TryGetProperty("email", out var emailEl)) profile.Email = emailEl.GetString(); if (userInfoEl.TryGetProperty("avatar", out var avatarEl)) profile.Avatar = avatarEl.GetString(); if (userInfoEl.TryGetProperty("address", out var addressEl)) profile.Address = addressEl.GetString(); if (userInfoEl.TryGetProperty("telephone", out var telephoneEl)) profile.OfficePhone = telephoneEl.GetString(); if (userInfoEl.TryGetProperty("workNo", out var workNoEl)) profile.JobNum = workNoEl.GetString(); if (userInfoEl.TryGetProperty("idCard", out var idCardEl)) profile.IdCardNum = idCardEl.GetString(); if (userInfoEl.TryGetProperty("description", out var descriptionEl)) profile.Remark = descriptionEl.GetString(); if (userInfoEl.TryGetProperty("birthday", out var birthdayEl) && birthdayEl.ValueKind == JsonValueKind.String && DateTime.TryParse(birthdayEl.GetString(), CultureInfo.InvariantCulture, DateTimeStyles.None, out var birthday)) { profile.Birthday = birthday; } if (userInfoEl.TryGetProperty("sex", out var sexEl)) { profile.Sex = ResolveUserSex(sexEl); } if (userInfoEl.TryGetProperty("status", out var statusEl)) { profile.Status = ResolveUserStatus(statusEl); } if (TryResolveTenantId(userInfoEl, out var tenantId)) { profile.TenantId = tenantId; } if (userInfoEl.TryGetProperty("updateTime", out var updateTimeEl)) { profile.JeecgSourceUpdateTime = TryParseJeecgRemoteDateTime(updateTimeEl); } if (userInfoEl.TryGetProperty("createTime", out var createTimeEl)) { profile.JeecgSourceCreateTime = TryParseJeecgRemoteDateTime(createTimeEl); } if (userInfoEl.TryGetProperty("id", out var idEl)) { profile.JeecgBizUserId = idEl.ValueKind switch { JsonValueKind.String => TruncateJeecgSyncField(idEl.GetString(), 64), JsonValueKind.Number when idEl.TryGetInt64(out var idNum) => idNum.ToString(CultureInfo.InvariantCulture), JsonValueKind.Number => TruncateJeecgSyncField(idEl.GetRawText(), 64), _ => profile.JeecgBizUserId }; } if (userInfoEl.TryGetProperty("orgCode", out var orgCodeEl) && orgCodeEl.ValueKind == JsonValueKind.String) { profile.OrgCode = TruncateJeecgSyncField(orgCodeEl.GetString(), 64); } if (userInfoEl.TryGetProperty("departIds", out var departIdsEl) && departIdsEl.ValueKind == JsonValueKind.String) { profile.DepartIds = TruncateJeecgSyncField(departIdsEl.GetString(), 512); } if (userInfoEl.TryGetProperty("post", out var postEl) && postEl.ValueKind == JsonValueKind.String) { profile.PosTitle = TruncateJeecgSyncField(postEl.GetString(), 32); } // Jeecg 密码密文(十六进制)与盐,用于本地按 PBEWithMD5AndDES 校验(与 SCADA 同步接口字段一致) if (userInfoEl.TryGetProperty("password", out var pwdHexEl) && pwdHexEl.ValueKind == JsonValueKind.String) { profile.PasswordHex = TruncateJeecgSyncField(pwdHexEl.GetString(), 512); } if (userInfoEl.TryGetProperty("salt", out var saltEl) && saltEl.ValueKind == JsonValueKind.String) { profile.Salt = TruncateJeecgSyncField(saltEl.GetString(), 64); } return profile; } /// /// 截断同步字段,避免超过库表长度 /// private static string? TruncateJeecgSyncField(string? value, int maxLen) { if (string.IsNullOrEmpty(value) || maxLen <= 0) { return value; } return value.Length <= maxLen ? value : value[..maxLen]; } private static bool TryResolveTenantId(JsonElement userInfoEl, out long tenantId) { tenantId = 0; if (userInfoEl.TryGetProperty("loginTenantId", out var loginTenantIdEl)) { if (loginTenantIdEl.ValueKind == JsonValueKind.Number && loginTenantIdEl.TryGetInt64(out tenantId) && tenantId > 0) { return true; } if (loginTenantIdEl.ValueKind == JsonValueKind.String && long.TryParse(loginTenantIdEl.GetString(), out tenantId) && tenantId > 0) { return true; } } // SCADA 等接口:tenantList[].tenantUserId if (userInfoEl.TryGetProperty("tenantList", out var tenantListEl) && tenantListEl.ValueKind == JsonValueKind.Array) { foreach (var t in tenantListEl.EnumerateArray()) { if (!t.TryGetProperty("tenantUserId", out var tuidEl)) { continue; } if (tuidEl.ValueKind == JsonValueKind.Number && tuidEl.TryGetInt64(out tenantId) && tenantId > 0) { return true; } if (tuidEl.ValueKind == JsonValueKind.String && long.TryParse(tuidEl.GetString(), out tenantId) && tenantId > 0) { return true; } } } if (userInfoEl.TryGetProperty("relTenantIds", out var relTenantIdsEl) && relTenantIdsEl.ValueKind == JsonValueKind.String) { var firstTenant = relTenantIdsEl.GetString()?.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).FirstOrDefault(); if (!string.IsNullOrWhiteSpace(firstTenant) && long.TryParse(firstTenant, out tenantId) && tenantId > 0) { return true; } } return false; } private sealed class JeecgUserProfile { public string? Username { get; set; } public string? RealName { get; set; } public string? NickName { get; set; } public string? Phone { get; set; } public string? Email { get; set; } public string? Avatar { get; set; } public string? Address { get; set; } public string? OfficePhone { get; set; } public string? JobNum { get; set; } public string? IdCardNum { get; set; } public string? Remark { get; set; } public DateTime? Birthday { get; set; } public GenderEnum? Sex { get; set; } public StatusEnum? Status { get; set; } public long? TenantId { get; set; } /// /// Jeecg 返回的 updateTime,用于跳过未变化的本地更新 /// public DateTime? JeecgSourceUpdateTime { get; set; } /// /// Jeecg createTime(仅新建本地用户时写入 CreateTime) /// public DateTime? JeecgSourceCreateTime { get; set; } public string? JeecgBizUserId { get; set; } public string? OrgCode { get; set; } public string? DepartIds { get; set; } public string? PosTitle { get; set; } /// Jeecg sys_user.password 十六进制密文 public string? PasswordHex { get; set; } /// Jeecg sys_user.salt public string? Salt { get; set; } } /// /// 解析Jeecg用户性别(1男、2女,其他未知) /// private static GenderEnum ResolveUserSex(JsonElement sexEl) { if (sexEl.ValueKind == JsonValueKind.Number && sexEl.TryGetInt32(out var numSex)) { return numSex switch { 1 => GenderEnum.Male, 2 => GenderEnum.Female, _ => GenderEnum.Unknown }; } if (sexEl.ValueKind == JsonValueKind.String && int.TryParse(sexEl.GetString(), out var strSex)) { return strSex switch { 1 => GenderEnum.Male, 2 => GenderEnum.Female, _ => GenderEnum.Unknown }; } return GenderEnum.Unknown; } /// /// 解析Jeecg用户状态(1启用,其他禁用) /// private static StatusEnum ResolveUserStatus(JsonElement statusEl) { if (statusEl.ValueKind == JsonValueKind.Number && statusEl.TryGetInt32(out var numStatus)) { return numStatus == 1 ? StatusEnum.Enable : StatusEnum.Disable; } if (statusEl.ValueKind == JsonValueKind.String && int.TryParse(statusEl.GetString(), out var strStatus)) { return strStatus == 1 ? StatusEnum.Enable : StatusEnum.Disable; } return StatusEnum.Disable; } /// /// 将常见英文异常转换成中文可读提示 /// private static string ToFriendlyJeecgErrorMessage(string? errorMessage) { if (string.IsNullOrWhiteSpace(errorMessage)) { return "未知错误,请检查Jeecg服务与网络连接"; } var msg = errorMessage.Trim(); if (msg.Contains("The requested operation requires an element of type 'Number', but the target element has type 'Null'", StringComparison.OrdinalIgnoreCase)) { return "后台返回的数据格式不正确:需要数字字段但返回了空值(null)"; } if (msg.Contains("No such host is known", StringComparison.OrdinalIgnoreCase)) { return "无法连接Jeecg服务,请检查后端地址或网络"; } if (msg.Contains("Connection refused", StringComparison.OrdinalIgnoreCase) || msg.Contains("积极拒绝", StringComparison.Ordinal) || msg.Contains("拒绝连接", StringComparison.Ordinal)) { return "Jeecg服务拒绝连接,请确认后端服务已启动"; } if (msg.Contains("timed out", StringComparison.OrdinalIgnoreCase)) { return "请求Jeecg超时,请检查网络或后端响应"; } return msg; } /// ///密码验证 /// /// /// /// /// /// public bool VerifyPassword(string password, string keyPasswordErrorTimes, int passwordErrorTimes, SysUser user) { // 已从 Jeecg 同步的用户:携带 salt 时仅按 JeecgBoot PasswordUtil(PBEWithMD5AndDES)校验,不再走 SM2/MD5 以免误判 if (!string.IsNullOrWhiteSpace(user.JeecgPasswordSalt)) { if (JeecgPasswordUtil.Verify(user.Account, password, user.JeecgPasswordSalt, user.Password, Encoding.UTF8)) { return true; } // 少数 Jeecg 环境 salt 按 JVM 默认编码落库,与 UTF-8 不一致时再尝试本机默认编码 if (Encoding.Default.CodePage != Encoding.UTF8.CodePage && JeecgPasswordUtil.Verify(user.Account, password, user.JeecgPasswordSalt, user.Password, Encoding.Default)) { return true; } _sysCacheService.Set(keyPasswordErrorTimes, ++passwordErrorTimes, TimeSpan.FromMinutes(30)); return false; } if (CryptogramUtil.CryptoType == CryptogramEnum.MD5.ToString()) { if (user.Password.Equals(CryptogramUtil.Encrypt(password)))return true; } else { if (CryptogramUtil.Decrypt(user.Password).Equals(password)) return true; } _sysCacheService.Set(keyPasswordErrorTimes, ++passwordErrorTimes, TimeSpan.FromMinutes(30)); return false; } public async Task IsAuthenticatedAsync() { await Task.Delay(50); return _currentUser != null; } private async Task getSysTokenExpireAsync() { var v = await _sysConfigService.GetConfigValue(ConfigConst.SysTokenExpire); return v > 0 ? v : 30; } /// /// 用户操作时续期阈值(分钟),默认 20 /// private async Task getSysTokenIdleExtendMinutesAsync() { var v = await _sysConfigService.GetConfigValue(ConfigConst.SysTokenIdleExtendMinutes); return v > 0 ? v : 20; } /// /// 是否开启永不过期 /// private async Task getSysTokenNeverExpireAsync() { return await _sysConfigService.GetConfigValue(ConfigConst.SysTokenNeverExpire); } /// ///创建token /// /// private async Task GenerateToken(SysUser user) { // 生成访问令牌(实际项目应使用JWT) var accessToken = GenerateSecureToken(32); var refreshToken = GenerateSecureToken(32); var neverExpire = await getSysTokenNeverExpireAsync(); var refreshExpires = await getSysTokenExpireAsync(); var refreshExpiration = neverExpire ? DateTime.MaxValue : DateTime.Now.AddMinutes(refreshExpires); // 存储Token关联信息 _tokenStore[accessToken] = new UserContext { UserId = user.Id, IsSuperAdmin = user.AccountType == AccountTypeEnum.SuperAdmin, Account= user.Account, AccountType = user.AccountType, OrgId= user.OrgId, RealName = user.RealName, TenantId= user.TenantId!.Value, Token=new UserToken() { RefreshToken = refreshToken, RefreshExpires = refreshExpiration, } }; return new UserToken { AccessToken = accessToken, RefreshToken = refreshToken, RefreshExpires = refreshExpiration }; } private string GenerateSecureToken(int length) { using var rng = RandomNumberGenerator.Create(); var tokenData = new byte[length]; rng.GetBytes(tokenData); return Convert.ToBase64String(tokenData); } /// /// 实现Token验证 /// /// /// public bool ValidateToken(string accessToken) { if (string.IsNullOrEmpty(accessToken)) return false; if (getSysTokenNeverExpireAsync().GetAwaiter().GetResult()) return _tokenStore.ContainsKey(accessToken); return _tokenStore.TryGetValue(accessToken, out var tokenInfo) && tokenInfo.Token.RefreshExpires >= DateTime.Now; } public async Task RefreshToken(string? accessToken) { if (string.IsNullOrEmpty(accessToken)) return; if (await getSysTokenNeverExpireAsync()) return; if (_tokenStore.TryGetValue(accessToken, out var tokenInfo)) { var idleMin = await getSysTokenIdleExtendMinutesAsync(); var idleSpan = TimeSpan.FromMinutes(idleMin); if (tokenInfo.Token.RefreshExpires - DateTime.Now <= idleSpan) { var refreshExpires = await getSysTokenExpireAsync(); tokenInfo.Token.RefreshExpires = DateTime.Now.AddMinutes(refreshExpires); } } } /// /// 实现刷新Token /// /// /// /// public async Task RefreshToken(string accessToken, string refreshToken) { if (!_tokenStore.TryGetValue(accessToken, out var tokenInfo) || tokenInfo.Token.RefreshToken != refreshToken || tokenInfo.Token.RefreshExpires < DateTime.Now) { return new RefreshTokenResult { Success = false }; } // 获取用户信息 var user = await _dbContext.Queryable() .Where(u => u.Id == tokenInfo.UserId) .FirstAsync(); if (user == null) return new RefreshTokenResult { Success = false }; // 生成新Token var newToken = GenerateSecureToken(32); var newRefreshToken = GenerateSecureToken(32); // 更新Token存储 _tokenStore.TryRemove(accessToken, out _); var refreshExpires = await getSysTokenExpireAsync(); var neverExpire = await getSysTokenNeverExpireAsync(); _tokenStore[newToken] = new UserContext { UserId = user.Id, IsSuperAdmin = user.AccountType == AccountTypeEnum.SuperAdmin, Account = user.Account, AccountType = user.AccountType, OrgId = user.OrgId, RealName = user.RealName, TenantId = user.TenantId!.Value, Token = new UserToken() { RefreshToken = refreshToken, RefreshExpires = neverExpire ? DateTime.MaxValue : DateTime.Now.AddMinutes(refreshExpires), } }; return new RefreshTokenResult { Success = true, AccessToken = newToken, RefreshToken = newRefreshToken }; } /// /// 增强退出登录实现 /// /// public void LogoutAsync() { if (_currentUser != null) { // 移除关联的Token var tokensToRemove = _tokenStore .Where(kvp => kvp.Value.UserId == _currentUser.Id) .Select(kvp => kvp.Key) .ToList(); foreach (var token in tokensToRemove) { _tokenStore.TryRemove(token, out _); } } AppSession.CurrentUser = null; // 更新静态会话 _currentUser = null; UserChanged?.Invoke(this, null); //await Task.Delay(1000); } public async Task UpdateUserLoginInfoAsync(SysUser user) { // 延迟1秒再执行后续的用户登录信息更新操作,目的是避免与主线程的资源竞争或操作冲突 // 如果不延迟执行会影响主窗口TabControl区域视图导航速度,影响用户体验 await Task.Delay(2000); user.LastLoginIp = await DeviceInfoUtil.GetPublicIpAddressAsync(); //(user.LastLoginAddress, double? longitude, double? latitude) = CommonUtil.GetIpAddress(user.LastLoginIp); user.LastLoginTime = DateTime.Now; user.LastLoginDevice = DeviceInfoUtil.GetOsVersion(); await _dbContext.Updateable(user).UpdateColumns(u => new { u.LastLoginIp, //u.LastLoginAddress, u.LastLoginTime, u.LastLoginDevice, }).ExecuteCommandAsync(); } } }