Files
qhmes/yy-admin-master/YY.Admin.Services/Service/Auth/SysAuthService.cs

2522 lines
110 KiB
C#
Raw Normal View History

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<SysUser?>? 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<string, UserContext> _tokenStore =
new ConcurrentDictionary<string, UserContext>();
private static bool _localIdentityResetDone = false;
private static long _localIdSeed = DateTime.UtcNow.Ticks;
public async Task<LoginOutput> LoginAsync(LoginInput request)
{
string? jeecgErrorMessage = null;
try
{
var jeecgEnabled = _configuration.GetValue<bool>("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}"
};
}
}
/// <summary>
/// 本地账号表jeecg_sys_user登录。
/// 后端断开时仅依赖此表,不调用远端接口。
/// </summary>
private async Task<LoginOutput> AuthenticateAgainstJeecgMirrorTableAsync(
LoginInput request,
string? jeecgFailureHint,
bool localFirstOfflineHint)
{
var keyPasswordErrorTimes = $"{CacheConst.KeyPasswordErrorTimes}{request.Username}";
var passwordErrorTimes = _sysCacheService.Get<int>(keyPasswordErrorTimes);
var passwordMaxErrorTimes = await _sysConfigService.GetConfigValue<int>(ConfigConst.SysPasswordMaxErrorTimes);
if (passwordMaxErrorTimes < 1)
{
passwordMaxErrorTimes = 10;
}
if (passwordErrorTimes >= passwordMaxErrorTimes)
{
return new LoginOutput
{
Success = false,
Message = "密码错误次数过多,账号已锁定,请半小时后重试!"
};
}
var mirrorCandidates = await _dbContext.Queryable<JeecgSysUser>().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}"
};
}
/// <summary>
/// 将 jeecg_sys_user 转换为本地会话所需 SysUser 结构。
/// </summary>
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<long?>("JeecgIntegration:DefaultTenantId") ?? 1300000000001),
AccountType = AccountTypeEnum.NormalUser,
OrgId = 0,
PosId = 0
};
}
/// <summary>
/// 快速探测 Jeecg 后端是否连通(登录前短超时)。
/// </summary>
private async Task<bool> IsJeecgBackendReachableAsync()
{
var baseUrl = _configuration.GetValue<string>("JeecgIntegration:BaseUrl")?.TrimEnd('/');
var userListPath = _configuration.GetValue<string>("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;
}
}
/// <summary>
/// 本地已失败时附加 Jeecg 原因,且不重复调用验密(避免错误次数翻倍)
/// </summary>
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}"
};
}
/// <summary>
/// 判断是否为 Jeecg 网络不可达类错误(中英文字符串均兼容)
/// </summary>
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);
}
/// <summary>
/// 通过 Jeecg 接口进行认证,并映射到本地用户
/// </summary>
private async Task<LoginOutput> LoginByJeecgAsync(LoginInput request)
{
try
{
var baseUrl = _configuration.GetValue<string>("JeecgIntegration:BaseUrl")?.TrimEnd('/');
if (string.IsNullOrWhiteSpace(baseUrl))
{
return new LoginOutput
{
Success = false,
Message = "JeecgIntegration 已启用,但未配置 BaseUrl"
};
}
var loginPath = _configuration.GetValue<string>("JeecgIntegration:LoginPath") ?? "/sys/login";
var userInfoPath = _configuration.GetValue<string>("JeecgIntegration:UserInfoPath") ?? "/sys/user/getUserInfo";
var loginUrl = $"{baseUrl}{loginPath}";
var userInfoUrl = $"{baseUrl}{userInfoPath}";
var payload = new Dictionary<string, object?>
{
["username"] = request.Username,
["password"] = request.Password,
["rememberMe"] = request.RememberMe
};
var captcha = _configuration.GetValue<string>("JeecgIntegration:Captcha");
var checkKey = _configuration.GetValue<string>("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<SysUser>()
.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<SysUser>().ClearFilter().OrderBy(u => u.Id).FirstAsync();
var defaultTenantId = _configuration.GetValue<long?>("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<SysUser>().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<SysUser>()
.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<SysUser>().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<SysUser>()
.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<long?>("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<SysUser>()
.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}"
};
}
}
/// <summary>
/// 同步 Jeecg 返回的租户列表到本地,字段以后端为准
/// </summary>
private async Task SyncTenantListAsync(JsonElement tenantListElement)
{
if (tenantListElement.ValueKind != JsonValueKind.Array) return;
var templateTenant = await _dbContext.Queryable<SysTenant>().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<SysTenant>().ClearFilter().Where(t => t.Id == tenantId).AnyAsync();
if (exists)
{
await _dbContext.Updateable<SysTenant>()
.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();
}
}
}
/// <summary>
/// 清空本地用户/角色/租户数据为Jeecg全量接管做准备
/// </summary>
private async Task ResetLocalIdentityDataAsync()
{
await _dbContext.Ado.BeginTranAsync();
try
{
await _dbContext.Deleteable<SysUserRole>().ExecuteCommandAsync();
await _dbContext.Deleteable<SysRoleMenu>().ExecuteCommandAsync();
await _dbContext.Deleteable<SysRole>().ExecuteCommandAsync();
await _dbContext.Deleteable<SysTenantMenu>().ExecuteCommandAsync();
await _dbContext.Deleteable<SysTenant>().ExecuteCommandAsync();
await _dbContext.Deleteable<SysUser>().ExecuteCommandAsync();
await _dbContext.Ado.CommitTranAsync();
// 清空 Jeecg 同步水位,否则仍按旧时间增量请求,首屏可能 0 条且误以为同步成功
try
{
new JeecgSyncStateStore().Save(new JeecgSyncState());
}
catch
{
// 忽略状态文件写入失败
}
}
catch
{
await _dbContext.Ado.RollbackTranAsync();
throw;
}
}
/// <summary>
/// 完全采用Jeecg权限同步当前用户角色与菜单权限明细到本地
/// </summary>
private async Task SyncJeecgPermissionToLocalAsync(string baseUrl, string jeecgToken, SysUser localUser)
{
var permissionPath = _configuration.GetValue<string>("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<string>(StringComparer.OrdinalIgnoreCase);
var routeKeys = new HashSet<string>(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<SysRole>().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<SysRole>().ClearFilter().Where(r => r.Code == roleCode).FirstAsync();
}
if (role == null) return;
await _dbContext.Deleteable<SysUserRole>().Where(x => x.UserId == localUser.Id).ExecuteCommandAsync();
var maxUserRoleId = await _dbContext.Queryable<SysUserRole>().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<SysRoleMenu>().Where(x => x.RoleId == role.Id).ExecuteCommandAsync();
var localMenus = await _dbContext.Queryable<SysMenu>().ClearFilter()
.Where(m => m.Type != MenuTypeEnum.Btn && m.Status == StatusEnum.Enable)
.ToListAsync();
var defaultTenantId = _configuration.GetValue<long?>("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<SysRoleMenu>().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();
}
}
/// <summary>
/// 递归提取Jeecg菜单中的路径、名称、权限编码
/// </summary>
private static void CollectJeecgMenuKeys(JsonElement node, HashSet<string> routeKeys, HashSet<string> 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<string> routeKeys, HashSet<string> 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<string> 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());
}
/// <summary>
/// 分配下一个本地用户主键sys_user 非自增Jeecg 建档时必须显式赋值)
/// </summary>
private async Task<long> TakeNextSysUserIdAsync()
{
var maxId = await _dbContext.Queryable<SysUser>().ClearFilter().MaxAsync(x => (long?)x.Id) ?? 0;
return maxId + 1;
}
/// <summary>
/// 分配下一个本地角色主键sys_role 非自增,多账号 Jeecg 同步各建角色时须避免 Id=0 重复)
/// </summary>
private async Task<long> TakeNextSysRoleIdAsync()
{
var maxId = await _dbContext.Queryable<SysRole>().ClearFilter().MaxAsync(x => (long?)x.Id) ?? 0;
return maxId + 1;
}
/// <summary>
/// 安全解析Jeecg租户状态避免null或字符串导致异常
/// </summary>
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;
}
/// <summary>
/// 有 Token 时附加 X-Access-TokenSCADA 免登录接口可不传)
/// </summary>
private static void AddJeecgAccessTokenIfPresent(HttpRequestMessage request, string? jeecgToken)
{
if (!string.IsNullOrWhiteSpace(jeecgToken))
{
request.Headers.TryAddWithoutValidation("X-Access-Token", jeecgToken);
}
}
/// <summary>
/// 从接口返回的用户数组中累计最大 updateTime/createTime转 UTC用于推进 SCADA 同步游标
/// </summary>
private static void AccumulateMaxJeecgSyncCursorUtc(IEnumerable<JsonElement> 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;
}
}
}
/// <summary>
/// 将一批 Jeecg/SCADA 用户 JSON 写入本地;若接口返回 password/salt如 SCADA 同步),则写入本地用于与 Jeecg 一致的登录校验。
/// </summary>
private async Task ProcessJeecgUserRecordsAsync(
IReadOnlyList<JsonElement> 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<SysUser>()
.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();
}
}
}
/// <summary>
/// 同步 Jeecg 用户:标准列表为 result.records 分页SCADA 为 result 数组分页 + 可选 updatedAfter免登录时可不传 Token。SCADA 若返回 password/salt 则写入本地。
/// </summary>
/// <param name="allowScadaIncrementalQuery">false 时如登录后首拉SCADA 不带 updatedAfter始终按页拉全量true 时(如定时后台)可按水位做增量。</param>
/// <returns>是否完成有效同步ApiRecordRows 为接口返回的用户记录条数(分页累计),为 0 时表示未拉到数据。</returns>
private async Task<(bool Success, int ApiRecordRows)> SyncAllJeecgUsersAsync(string baseUrl, string jeecgToken, SysUser currentUser, bool allowScadaIncrementalQuery = true)
{
var userListPath = _configuration.GetValue<string>("JeecgIntegration:UserListPath") ?? "/sys/user/list";
var templateCandidates = await _dbContext.Queryable<SysUser>().ClearFilter().OrderBy(u => u.Id).Take(1).ToListAsync();
var templateUser = templateCandidates.FirstOrDefault() ?? currentUser;
var defaultTenantId = _configuration.GetValue<long?>("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<JsonElement> 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);
}
/// <summary>
/// 登录页一键同步:依赖 SCADA 免登录 queryUser无需 Token空库时用配置租户/机构占位写入新用户。
/// </summary>
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<string>("JeecgIntegration:BaseUrl")?.TrimEnd('/');
if (string.IsNullOrWhiteSpace(baseUrl))
{
return (false, "未配置 JeecgIntegration:BaseUrl。");
}
var userListPath = _configuration.GetValue<string>("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}");
}
}
/// <summary>
/// 登录页按钮专用:将 SCADA 接口用户同步到 Jeecg 同构表jeecg_sys_user
/// </summary>
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<string>(StringComparer.OrdinalIgnoreCase);
var remoteUsernames = new HashSet<string>(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<JsonElement> 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);
}
/// <summary>
/// 收集远端用户主键集合,用于全量同步后的本地删除对齐。
/// </summary>
private static void CollectRemoteUserKeys(
IReadOnlyList<JsonElement> records,
ISet<string> remoteIds,
ISet<string> 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);
}
}
}
}
/// <summary>
/// 删除本地 jeecg_sys_user 中已被后端删除的账号,保持镜像一致。
/// </summary>
private async Task<int> DeleteMissingJeecgMirrorUsersAsync(ISet<string> remoteIds, ISet<string> remoteUsernames)
{
var localRows = await _dbContext.Queryable<JeecgSysUser>()
.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<JeecgSysUser>()
.In(it => it.Id, staleIds)
.ExecuteCommandAsync();
_logger.Information($"Jeecg镜像删除同步: 表=jeecg_sys_user, 删除行数={deletedRows}");
return deletedRows;
}
/// <summary>
/// 写入 Jeecg 同构用户表 jeecg_sys_user按 id 或 username 做幂等更新)。
/// </summary>
private async Task<int> UpsertJeecgMirrorUsersAsync(IReadOnlyList<JsonElement> 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<JeecgSysUser>()
.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) ? "<null>" : value;
}
/// <summary>
/// 后台使用缓存 Token 同步用户(不弹窗)
/// </summary>
public async Task<bool> TryBackgroundSyncJeecgUsersAsync(CancellationToken cancellationToken = default)
{
if (!_configuration.GetValue("JeecgIntegration:Enabled", false))
{
return false;
}
if (!_configuration.GetValue("JeecgIntegration:SyncAllUsersOnJeecgLogin", false))
{
return false;
}
var userListPath = _configuration.GetValue<string>("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<string>($"jeecg:token:{user.Id}");
if (string.IsNullOrWhiteSpace(token) && !isScadaUserPath)
{
return false;
}
var baseUrl = _configuration.GetValue<string>("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<SysUserEvents.JeecgMirrorUsersSyncedEvent>().Publish(rows);
}
return ok;
}
catch (OperationCanceledException)
{
throw;
}
catch
{
return false;
}
}
/// <summary>
/// 解析 Jeecg userInfo.id字符串或数字雪花超出 long 范围则返回 null
/// </summary>
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;
}
}
/// <summary>
/// 将 sys_user 主键从 oldId 改为 newIdSQLite并同步常见子表 user_id
/// </summary>
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;
}
/// <summary>
/// 截断同步字段,避免超过库表长度
/// </summary>
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; }
/// <summary>
/// Jeecg 返回的 updateTime用于跳过未变化的本地更新
/// </summary>
public DateTime? JeecgSourceUpdateTime { get; set; }
/// <summary>
/// Jeecg createTime仅新建本地用户时写入 CreateTime
/// </summary>
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; }
/// <summary>Jeecg sys_user.password 十六进制密文</summary>
public string? PasswordHex { get; set; }
/// <summary>Jeecg sys_user.salt</summary>
public string? Salt { get; set; }
}
/// <summary>
/// 解析Jeecg用户性别1男、2女其他未知
/// </summary>
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;
}
/// <summary>
/// 解析Jeecg用户状态1启用其他禁用
/// </summary>
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;
}
/// <summary>
/// 将常见英文异常转换成中文可读提示
/// </summary>
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;
}
/// <summary>
///密码验证
/// </summary>
/// <param name="password"></param>
/// <param name="keyPasswordErrorTimes"></param>
/// <param name="passwordErrorTimes"></param>
/// <param name="user"></param>
/// <returns></returns>
public bool VerifyPassword(string password, string keyPasswordErrorTimes, int passwordErrorTimes, SysUser user)
{
// 已从 Jeecg 同步的用户:携带 salt 时仅按 JeecgBoot PasswordUtilPBEWithMD5AndDES校验不再走 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<bool> IsAuthenticatedAsync()
{
await Task.Delay(50);
return _currentUser != null;
}
private async Task<int> getSysTokenExpireAsync()
{
var v = await _sysConfigService.GetConfigValue<int>(ConfigConst.SysTokenExpire);
return v > 0 ? v : 30;
}
/// <summary>
/// 用户操作时续期阈值(分钟),默认 20
/// </summary>
private async Task<int> getSysTokenIdleExtendMinutesAsync()
{
var v = await _sysConfigService.GetConfigValue<int>(ConfigConst.SysTokenIdleExtendMinutes);
return v > 0 ? v : 20;
}
/// <summary>
/// 是否开启永不过期
/// </summary>
private async Task<bool> getSysTokenNeverExpireAsync()
{
return await _sysConfigService.GetConfigValue<bool>(ConfigConst.SysTokenNeverExpire);
}
/// <summary>
///创建token
/// </summary>
/// <returns></returns>
private async Task<UserToken> 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);
}
/// <summary>
/// 实现Token验证
/// </summary>
/// <param name="accessToken"></param>
/// <returns></returns>
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);
}
}
}
/// <summary>
/// 实现刷新Token
/// </summary>
/// <param name="accessToken"></param>
/// <param name="refreshToken"></param>
/// <returns></returns>
public async Task<RefreshTokenResult> 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<SysUser>()
.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
};
}
/// <summary>
/// 增强退出登录实现
/// </summary>
/// <returns></returns>
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();
}
}
}