2522 lines
110 KiB
C#
2522 lines
110 KiB
C#
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-Token(SCADA 免登录接口可不传)
|
||
/// </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 改为 newId(SQLite),并同步常见子表 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 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<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();
|
||
}
|
||
}
|
||
}
|