更新项目配置,新增设备同步模块,优化WebSocket和Swagger配置,增强SCADA系统的免登录接口,支持数据字典项和登录日志的免登录查询与记录。调整Java编译设置,确保更好的开发体验。
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
{
|
||||
// 缓存配置
|
||||
"Cache": {
|
||||
"Prefix": "yyadmin_", // 全局缓存前缀
|
||||
"CacheType": "Memory", // Memory、Redis
|
||||
"Redis": {
|
||||
"Configuration": "server=localhost;db=2;password=123456;", // Redis连接字符串
|
||||
"Prefix": "yyadmin_", // Redis前缀(目前没用)
|
||||
"MaxMessageSize": "1048576" // 最大消息大小 默认1024 * 1024
|
||||
}
|
||||
},
|
||||
// 数据库连接字符串参考地址:https://www.connectionstrings.com/
|
||||
"DbConnection": {
|
||||
"EnableConsoleSql": true, // 启用控制台打印SQL
|
||||
"ConnectionConfigs": [
|
||||
{
|
||||
"ConfigId": "1300000000001", // 默认库标识-禁止修改
|
||||
"DbType": "Sqlite", // MySql、SqlServer、Sqlite、Oracle、PostgreSQL、Dm、Kdbndp、Oscar、MySqlConnector、Access、OpenGauss、QuestDB、HG、ClickHouse、GBase、Odbc、Custom
|
||||
"DbNickName": "系统库",
|
||||
//"ConnectionString": "DataSource=./Admin.NET.db", // Sqlite
|
||||
"ConnectionString": "DataSource=./Admin.NET.db", // Sqlite
|
||||
//"ConnectionString": "PORT=5432;DATABASE=xxx;HOST=localhost;PASSWORD=xxx;USER ID=xxx", // PostgreSQL
|
||||
//"ConnectionString": "server= ;port=;database=;user=;password=;CharSet=utf8;sslmode=none;max pool size=1000;", // MySql,
|
||||
"DbSettings": {
|
||||
"EnableInitDb": true, // 启用库初始化(若实体没有变化建议关闭)
|
||||
"EnableInitView": false, // 启用视图初始化(若实体和视图没有变化建议关闭)
|
||||
"EnableDiffLog": false, // 启用库表差异日志
|
||||
"EnableUnderLine": true, // 启用驼峰转下划线
|
||||
"EnableConnEncrypt": false // 启用数据库连接串加密(国密SM2加解密)
|
||||
},
|
||||
"TableSettings": {
|
||||
"EnableInitTable": true, // 启用表初始化(若实体没有变化建议关闭)
|
||||
"EnableIncreTable": false // 启用表增量更新(只更新贴了特性[IncreTable]的实体表)
|
||||
},
|
||||
"SeedSettings": {
|
||||
"EnableInitSeed": true, // 启用种子初始化(若种子没有变化建议关闭)
|
||||
"EnableIncreSeed": false // 启用种子增量更新(只更新贴了特性[IncreSeed]的种子表)
|
||||
}
|
||||
},
|
||||
{
|
||||
"ConfigId": "Slave", // 从数据库
|
||||
"DbType": "Sqlite", // 数据库类型
|
||||
"DbNickName": "业务库",
|
||||
"ConnectionString": "Data Source=./Slave.db", // Sqlite
|
||||
"DbSettings": {
|
||||
"EnableInitDb": true, // 启用库初始化(若实体没有变化建议关闭)
|
||||
"EnableInitView": false, // 启用视图初始化(若实体和视图没有变化建议关闭)
|
||||
"EnableDiffLog": false, // 启用库表差异日志
|
||||
"EnableUnderLine": true, // 启用驼峰转下划线
|
||||
"EnableConnEncrypt": false // 启用数据库连接串加密(国密SM2加解密)
|
||||
},
|
||||
"TableSettings": {
|
||||
"EnableInitTable": true, // 启用表初始化(若实体没有变化建议关闭)
|
||||
"EnableIncreTable": false // 启用表增量更新(只更新贴了特性[IncreTable]的实体表)
|
||||
},
|
||||
"SeedSettings": {
|
||||
"EnableInitSeed": true, // 启用种子初始化(若种子没有变化建议关闭)
|
||||
"EnableIncreSeed": false // 启用种子增量更新(只更新贴了特性[IncreSeed]的种子表)
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"AutoUpdate": {
|
||||
"RemoteConfigUrl": "http://14.103.155.227:8083/updates/version.xml" //更新文件地址
|
||||
},
|
||||
"JeecgIntegration": {
|
||||
"Enabled": true, // 是否启用Jeecg(可与本地并存;见 PreferLocalLogin 决定先后顺序)
|
||||
"PreferLocalLogin": true, // true:先校验本地库再尝试 Jeecg(工控脱网时无需等待 MES,直接用本地账号登录)
|
||||
"FallbackToLocal": true, // false:仅 MES 登录,失败即结束(不推荐工控场景)
|
||||
"BaseUrl": "http://127.0.0.1:8080/jeecg-boot", // Jeecg后端地址(按实际环境修改)
|
||||
"LoginPath": "/sys/login", // Jeecg登录接口
|
||||
"UserInfoPath": "/sys/user/getUserInfo", // Jeecg用户信息接口
|
||||
"UserListPath": "/sys/user/scada/queryUser", // Jeecg 用户列表(SCADA:分页 + updatedAfter 增量,见文档)
|
||||
"ScadaUserPageSize": 500, // SCADA queryUser 每页条数,最大 1000
|
||||
"ScadaUserIncludeDetail": false, // true 时含部门/公司/租户明细,耗时更高
|
||||
"ScadaUseUpdatedAfter": true, // 后台/定时同步:有水位时用 updatedAfter 增量。登录触发的 SCADA 同步固定全量分页,避免只拉到少数变更用户
|
||||
"TenantListPath": "/sys/tenant/list", // Jeecg租户分页接口
|
||||
"UserPermissionPath": "/sys/permission/getUserPermissionByToken", // Jeecg当前用户菜单与按钮权限接口
|
||||
"ResetLocalIdentityDataOnJeecgLogin": false, // true 时 Jeecg 登录成功会清空本地用户/角色等(易丢失种子账号导致脱网无法登录)。工控独立运行建议保持 false
|
||||
"AutoProvisionLocalUser": true, // Jeecg认证成功后本地不存在账号时自动创建
|
||||
"SyncUserProfileToLocal": true, // 每次登录时同步Jeecg用户基础信息到本地
|
||||
"SyncAllUsersOnJeecgLogin": true, // true:Jeecg 登录成功后拉取用户列表并写入本地(关闭则不会同步用户表)
|
||||
"UseJeecgUserIdAsLocalPrimaryKey": true, // true 时本地 sys_user.id 与 Jeecg getUserInfo 的 id 一致(雪花 long)
|
||||
"UserSyncSkipUnchanged": true, // 与Jeecg updateTime 一致时跳过写库,减少重复保存
|
||||
"UserListUseUpdateTimeQuery": false, // 仅非 SCADA 的 /sys/user/list:true 时附带 updateTime_begin。SCADA 增量由 ScadaUseUpdatedAfter 控制
|
||||
"IncrementalSyncOverlapMinutes": 2, // 增量时间窗口重叠,避免时钟误差漏数据
|
||||
"BackgroundSyncIntervalMinutes": 30, // 主窗口在线时定时增量同步间隔(分钟)
|
||||
"AnonymousMode": true, // 工控机免密模式:优先走免登录接口与匿名WebSocket通道
|
||||
"WebSocketUrl": "", // 可选:Jeecg 或自建推送地址;免密模式下若误配到 /ws/device/websocket 会自动切回 /websocket/scada-sync
|
||||
"WebSocketPath": "/websocket/scada-sync", // 匿名实时推送通道
|
||||
"WebSocketInactivityReconnectSeconds": 0, // 0=关闭空闲强制重连;仅保留WS心跳保活,避免重连窗口丢推送
|
||||
"DefaultTenantId": 1002, // 自动创建本地用户时使用的默认租户ID
|
||||
"Captcha": "", // 如启用登录验证码,在此传入验证码
|
||||
"CheckKey": "" // 如启用登录验证码,在此传入验证码key
|
||||
}
|
||||
}
|
||||
6
yy-admin-master/YY.Admin.Services/GlobalUsings.cs
Normal file
6
yy-admin-master/YY.Admin.Services/GlobalUsings.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
global using YY.Admin.Core.Const;
|
||||
global using YY.Admin.Core.SqlSugar;
|
||||
global using YY.Admin.Core;
|
||||
global using static YY.Admin.Core.SysUserEvents;
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace YY.Admin.Services;
|
||||
public class LoginInput
|
||||
{
|
||||
public string Username { get; set; } = string.Empty;
|
||||
public string Password { get; set; } = string.Empty;
|
||||
public long TenantId { get; set; }
|
||||
public bool RememberMe { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace YY.Admin.Services
|
||||
{
|
||||
public class LoginOutput
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public string Message { get; set; } = string.Empty;
|
||||
public SysUser? User { get; set; }
|
||||
public UserToken Token { get; set; }
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
|
||||
namespace YY.Admin.Services.Service.Auth
|
||||
{
|
||||
public interface ISysAuthService
|
||||
{
|
||||
Task<LoginOutput> LoginAsync(LoginInput request);
|
||||
void LogoutAsync();
|
||||
Task<bool> IsAuthenticatedAsync();
|
||||
bool ValidateToken(string accessToken);
|
||||
Task RefreshToken(string? accessToken);
|
||||
SysUser? CurrentUser { get; }
|
||||
event EventHandler<SysUser?> UserChanged;
|
||||
|
||||
Task UpdateUserLoginInfoAsync(SysUser sysUser);
|
||||
|
||||
/// <summary>
|
||||
/// 使用缓存的 Jeecg Token 做用户增量同步(定时/WebSocket 调用,断网恢复后自动补拉)
|
||||
/// </summary>
|
||||
Task<bool> TryBackgroundSyncJeecgUsersAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 登录页一键同步 Jeecg 用户到本地 SQLite(依赖 SCADA 免登录 queryUser,无需先登录)
|
||||
/// </summary>
|
||||
Task<(bool Success, string Message)> SyncJeecgUsersToLocalFromLoginScreenAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
}
|
||||
2501
yy-admin-master/YY.Admin.Services/Service/Auth/SysAuthService.cs
Normal file
2501
yy-admin-master/YY.Admin.Services/Service/Auth/SysAuthService.cs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,190 @@
|
||||
using AutoUpdaterDotNET;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Reflection;
|
||||
using System.Xml;
|
||||
|
||||
namespace YY.Admin.Services.Service.AutoUpdate
|
||||
{
|
||||
public class AutoUpdateService : IAutoUpdateService
|
||||
{
|
||||
private readonly IConfiguration _configuration;
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
public AutoUpdateService(IConfiguration configuration, HttpClient httpClient)
|
||||
{
|
||||
_configuration = configuration;
|
||||
_httpClient = httpClient;
|
||||
}
|
||||
/// <summary>
|
||||
/// 获取当前程序版本
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public string GetCurrentVersion()
|
||||
{
|
||||
try
|
||||
{
|
||||
var assembly = Assembly.GetEntryAssembly() ?? Assembly.GetExecutingAssembly();
|
||||
var version = assembly.GetName().Version;
|
||||
return version?.ToString() ?? "1.0.0.0";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
System.Diagnostics.Debug.WriteLine($"获取当前版本失败: {ex.Message}");
|
||||
return "1.0.0.0";
|
||||
}
|
||||
}
|
||||
/// <summary>
|
||||
/// 获取程序版本信息(包含版本号、产品名称等)
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public VersionInfo GetApplicationInfo()
|
||||
{
|
||||
try
|
||||
{
|
||||
var assembly = Assembly.GetEntryAssembly() ?? Assembly.GetExecutingAssembly();
|
||||
var version = assembly.GetName().Version;
|
||||
var title = assembly.GetCustomAttribute<AssemblyTitleAttribute>()?.Title;
|
||||
var product = assembly.GetCustomAttribute<AssemblyProductAttribute>()?.Product;
|
||||
var company = assembly.GetCustomAttribute<AssemblyCompanyAttribute>()?.Company;
|
||||
|
||||
return new VersionInfo
|
||||
{
|
||||
CurrentVersion = version?.ToString() ?? "1.0.0.0",
|
||||
ApplicationName = title ?? product ?? "应用程序",
|
||||
CompanyName = company ?? "",
|
||||
PublishDate = File.GetLastWriteTime(assembly.Location).ToString("yyyy-MM-dd")
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
System.Diagnostics.Debug.WriteLine($"获取应用程序信息失败: {ex.Message}");
|
||||
return new VersionInfo
|
||||
{
|
||||
CurrentVersion = "1.0.0.0",
|
||||
ApplicationName = "应用程序",
|
||||
CompanyName = "",
|
||||
PublishDate = DateTime.Now.ToString("yyyy-MM-dd")
|
||||
};
|
||||
}
|
||||
}
|
||||
public async Task<bool> CheckForUpdatesAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
VersionInfo? versionInfo = await ReadRemoteVersionInfoAsync();
|
||||
|
||||
if (versionInfo == null) return false;
|
||||
|
||||
var currentVersion = new Version(GetCurrentVersion());
|
||||
var latestVersion = new Version(versionInfo.LatestVersion);
|
||||
|
||||
return latestVersion > currentVersion;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
System.Diagnostics.Debug.WriteLine($"自动更新检查失败: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public async Task<VersionInfo?> GetVersionInfo()
|
||||
{
|
||||
try
|
||||
{
|
||||
VersionInfo? versionInfo = await ReadRemoteVersionInfoAsync();
|
||||
if (versionInfo == null) return null;
|
||||
|
||||
versionInfo.CurrentVersion = versionInfo.CurrentVersion;
|
||||
versionInfo.ApplicationName = versionInfo.ApplicationName;
|
||||
|
||||
return versionInfo;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
System.Diagnostics.Debug.WriteLine($"获取版本信息失败: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<VersionInfo?> ReadRemoteVersionInfoAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
// 从配置文件读取远程更新地址
|
||||
string remoteConfigUrl = _configuration.GetSection("AutoUpdate:RemoteConfigUrl").Value!;
|
||||
|
||||
var xmlContent = await _httpClient.GetStringAsync(remoteConfigUrl);
|
||||
return ParseVersionXml(xmlContent);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
System.Diagnostics.Debug.WriteLine($"读取远程版本信息失败: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private VersionInfo? ParseVersionXml(string xmlContent)
|
||||
{
|
||||
try
|
||||
{
|
||||
var xmlDoc = new XmlDocument();
|
||||
xmlDoc.LoadXml(xmlContent);
|
||||
|
||||
var item = xmlDoc.SelectSingleNode("item");
|
||||
if (item != null)
|
||||
{
|
||||
return new VersionInfo
|
||||
{
|
||||
LatestVersion = item.SelectSingleNode("version")?.InnerText ?? "1.0.0",
|
||||
DownloadUrl = item.SelectSingleNode("url")?.InnerText ?? "",
|
||||
Changelog = item.SelectSingleNode("changelog")?.InnerText ?? "暂无更新内容",
|
||||
PublishDate = item.SelectSingleNode("publishDate")?.InnerText ?? DateTime.Now.ToString("yyyy-MM-dd"),
|
||||
Mandatory = bool.TryParse(item.SelectSingleNode("mandatory")?.InnerText, out var mandatory) && mandatory
|
||||
};
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
System.Diagnostics.Debug.WriteLine($"解析版本XML失败: {ex.Message}");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
public void StartUpdate(string downloadUrl)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 设置更新完成后的回调
|
||||
AutoUpdater.ApplicationExitEvent += () =>
|
||||
{
|
||||
// 更新完成后可以执行一些清理操作
|
||||
System.Diagnostics.Debug.WriteLine("应用程序准备退出以完成更新");
|
||||
};
|
||||
|
||||
AutoUpdater.Start(downloadUrl);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new Exception($"更新启动失败: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否需要强制更新
|
||||
public bool IsMandatoryUpdate(VersionInfo versionInfo)
|
||||
{
|
||||
if (versionInfo == null) return false;
|
||||
|
||||
var currentVersion = new Version(GetCurrentVersion());
|
||||
var latestVersion = new Version(versionInfo.LatestVersion);
|
||||
|
||||
return versionInfo.Mandatory && latestVersion > currentVersion;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace YY.Admin.Services.Service.AutoUpdate
|
||||
{
|
||||
public interface IAutoUpdateService
|
||||
{
|
||||
Task<bool> CheckForUpdatesAsync();
|
||||
void StartUpdate(string downloadUrl);
|
||||
Task<VersionInfo?> GetVersionInfo();
|
||||
string GetCurrentVersion();
|
||||
}
|
||||
|
||||
public class VersionInfo
|
||||
{
|
||||
public string CurrentVersion { get; set; } = "1.0.0.0";
|
||||
public string LatestVersion { get; set; } = "1.0.0";
|
||||
public string DownloadUrl { get; set; } = "";
|
||||
public string Changelog { get; set; } = "暂无更新内容";
|
||||
public string PublishDate { get; set; } = "";
|
||||
public string ApplicationName { get; set; } = "应用程序";
|
||||
public string CompanyName { get; set; } = "";
|
||||
public bool Mandatory { get; set; } = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace YY.Admin.Services.Service.Config
|
||||
{
|
||||
public interface ISysConfigService
|
||||
{
|
||||
Task<T> GetConfigValue<T>(string code);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using SqlSugar;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace YY.Admin.Services.Service.Config
|
||||
{
|
||||
public class SysConfigService : ISysConfigService, ISingletonDependency
|
||||
{
|
||||
private readonly ISysCacheService _sysCacheService;
|
||||
private readonly ISqlSugarClient _dbContext;
|
||||
public SysConfigService(
|
||||
ISysCacheService sysCacheService,
|
||||
ISqlSugarClient dbContext)
|
||||
{
|
||||
_sysCacheService= sysCacheService;
|
||||
_dbContext= dbContext;
|
||||
}
|
||||
public async Task<T> GetConfigValue<T>(string code)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(code)) return default;
|
||||
|
||||
var value = _sysCacheService.Get<string>($"{CacheConst.KeyConfig}{code}");
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
value = (await _dbContext.Queryable<SysConfig>().FirstAsync(u => u.Code == code))?.Value;
|
||||
_sysCacheService.Set($"{CacheConst.KeyConfig}{code}", value);
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(value)) return default;
|
||||
return (T)Convert.ChangeType(value, typeof(T));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
namespace YY.Admin.Services.Service;
|
||||
|
||||
/// <summary>
|
||||
/// 数据字典项输出
|
||||
/// </summary>
|
||||
public class JeecgDictItemOutput
|
||||
{
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
public string? DictCode { get; set; }
|
||||
|
||||
public string? DictName { get; set; }
|
||||
|
||||
public string? ItemText { get; set; }
|
||||
|
||||
public string? ItemValue { get; set; }
|
||||
|
||||
public string? ItemDescription { get; set; }
|
||||
|
||||
public int? SortOrder { get; set; }
|
||||
|
||||
public StatusEnum Status { get; set; } = StatusEnum.Disable;
|
||||
|
||||
public string? ItemColor { get; set; }
|
||||
|
||||
public DateTime? CreateTime { get; set; }
|
||||
|
||||
public DateTime? UpdateTime { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace YY.Admin.Services.Service;
|
||||
|
||||
/// <summary>
|
||||
/// 数据字典分页查询参数
|
||||
/// </summary>
|
||||
public class PageJeecgDictItemInput : PagedRequestBase
|
||||
{
|
||||
public string? DictCode { get; set; }
|
||||
|
||||
public string? DictName { get; set; }
|
||||
|
||||
public string? ItemText { get; set; }
|
||||
|
||||
public string? ItemValue { get; set; }
|
||||
|
||||
public StatusEnum? Status { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace YY.Admin.Services.Service;
|
||||
|
||||
public interface IJeecgDictSyncService
|
||||
{
|
||||
Task<SqlSugarPagedList<JeecgDictItemOutput>> PageAsync(PageJeecgDictItemInput input);
|
||||
|
||||
/// <summary>
|
||||
/// 从 Jeecg 后端同步数据字典到本地同构表
|
||||
/// </summary>
|
||||
Task<int> SyncFromJeecgAsync();
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using SqlSugar;
|
||||
using System.Text.Json;
|
||||
using YY.Admin.Core;
|
||||
using YY.Admin.Services.Service.Jeecg;
|
||||
|
||||
namespace YY.Admin.Services.Service;
|
||||
|
||||
public class JeecgDictSyncService : IJeecgDictSyncService, ISingletonDependency
|
||||
{
|
||||
private readonly ISqlSugarClient _dbContext;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly IJeecgBackendGateway _jeecgGateway;
|
||||
|
||||
public JeecgDictSyncService(
|
||||
ISqlSugarClient dbContext,
|
||||
IConfiguration configuration,
|
||||
IJeecgBackendGateway jeecgGateway)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
_configuration = configuration;
|
||||
_jeecgGateway = jeecgGateway;
|
||||
}
|
||||
|
||||
public async Task<SqlSugarPagedList<JeecgDictItemOutput>> PageAsync(PageJeecgDictItemInput input)
|
||||
{
|
||||
var statusFilter = input.Status.HasValue ? (int?)input.Status.Value : null;
|
||||
var query = _dbContext.Queryable<JeecgSysDictItem>().ClearFilter()
|
||||
.WhereIF(!string.IsNullOrWhiteSpace(input.DictCode), x => x.DictCode != null && x.DictCode.Contains(input.DictCode))
|
||||
.WhereIF(!string.IsNullOrWhiteSpace(input.DictName), x => x.DictName != null && x.DictName.Contains(input.DictName))
|
||||
.WhereIF(!string.IsNullOrWhiteSpace(input.ItemText), x => x.ItemText != null && x.ItemText.Contains(input.ItemText))
|
||||
.WhereIF(!string.IsNullOrWhiteSpace(input.ItemValue), x => x.ItemValue != null && x.ItemValue.Contains(input.ItemValue));
|
||||
|
||||
if (statusFilter.HasValue)
|
||||
{
|
||||
var statusValue = statusFilter.Value;
|
||||
query = query.Where(x => x.Status == statusValue);
|
||||
}
|
||||
|
||||
query = query.OrderBy(x => SqlFunc.Asc(x.DictCode))
|
||||
.OrderBy(x => SqlFunc.Asc(x.SortOrder))
|
||||
.OrderBy(x => SqlFunc.Desc(x.CreateTime));
|
||||
|
||||
RefAsync<int> total = 0;
|
||||
var list = await query.ToPageListAsync(input.Page, input.PageSize, total);
|
||||
var items = list.Select(x => new JeecgDictItemOutput
|
||||
{
|
||||
Id = x.Id,
|
||||
DictCode = x.DictCode,
|
||||
DictName = x.DictName,
|
||||
ItemText = x.ItemText,
|
||||
ItemValue = x.ItemValue,
|
||||
ItemDescription = x.ItemDescription,
|
||||
SortOrder = x.SortOrder,
|
||||
Status = x.Status == 1 ? StatusEnum.Enable : StatusEnum.Disable,
|
||||
ItemColor = x.ItemColor,
|
||||
CreateTime = x.CreateTime,
|
||||
UpdateTime = x.UpdateTime
|
||||
}).ToList();
|
||||
|
||||
return new SqlSugarPagedList<JeecgDictItemOutput>
|
||||
{
|
||||
Page = input.Page,
|
||||
PageSize = input.PageSize,
|
||||
Total = total,
|
||||
TotalPages = input.PageSize > 0 ? (int)Math.Ceiling(total / (double)input.PageSize) : 0,
|
||||
HasNextPage = input.PageSize > 0 && input.Page < (int)Math.Ceiling(total / (double)input.PageSize),
|
||||
HasPrevPage = input.Page > 1,
|
||||
Items = items
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<int> SyncFromJeecgAsync()
|
||||
{
|
||||
var baseUrl = _configuration.GetValue<string>("JeecgIntegration:BaseUrl")?.TrimEnd('/');
|
||||
if (string.IsNullOrWhiteSpace(baseUrl))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var dictPath = _configuration.GetValue<string>("JeecgIntegration:DictListPath") ?? "/sys/dict/scada/queryDictItem";
|
||||
const int pageSize = 500;
|
||||
var pageNo = 1;
|
||||
var synced = 0;
|
||||
|
||||
while (true)
|
||||
{
|
||||
var requestUrl = $"{baseUrl}{dictPath}?pageNo={pageNo}&pageSize={pageSize}";
|
||||
var json = await _jeecgGateway.ExecuteGetStringAsync(requestUrl);
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
break;
|
||||
}
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
if (!(root.TryGetProperty("success", out var successEl) && successEl.GetBoolean()))
|
||||
{
|
||||
break;
|
||||
}
|
||||
if (!root.TryGetProperty("result", out var recordsEl) || recordsEl.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var currentBatch = 0;
|
||||
foreach (var row in recordsEl.EnumerateArray())
|
||||
{
|
||||
var id = GetString(row, "id");
|
||||
if (string.IsNullOrWhiteSpace(id))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var existing = await _dbContext.Queryable<JeecgSysDictItem>()
|
||||
.ClearFilter()
|
||||
.Where(x => x.Id == id)
|
||||
.FirstAsync();
|
||||
|
||||
if (existing == null)
|
||||
{
|
||||
existing = new JeecgSysDictItem { Id = id };
|
||||
}
|
||||
|
||||
existing.DictId = GetString(row, "dictId");
|
||||
existing.DictName = GetString(row, "dictName");
|
||||
existing.DictCode = GetString(row, "dictCode");
|
||||
existing.DictType = GetInt(row, "dictType");
|
||||
existing.DictDescription = GetString(row, "dictDescription");
|
||||
existing.ItemText = GetString(row, "itemText");
|
||||
existing.ItemValue = GetString(row, "itemValue");
|
||||
existing.ItemDescription = GetString(row, "itemDescription");
|
||||
existing.SortOrder = GetInt(row, "sortOrder");
|
||||
existing.Status = GetInt(row, "status");
|
||||
existing.ItemColor = GetString(row, "itemColor");
|
||||
existing.CreateBy = GetString(row, "createBy");
|
||||
existing.CreateTime = GetDateTime(row, "createTime");
|
||||
existing.UpdateBy = GetString(row, "updateBy");
|
||||
existing.UpdateTime = GetDateTime(row, "updateTime");
|
||||
|
||||
var existsCount = await _dbContext.Queryable<JeecgSysDictItem>()
|
||||
.ClearFilter()
|
||||
.Where(x => x.Id == existing.Id)
|
||||
.CountAsync();
|
||||
if (existsCount > 0)
|
||||
{
|
||||
await _dbContext.Updateable(existing).ExecuteCommandAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
await _dbContext.Insertable(existing).ExecuteCommandAsync();
|
||||
}
|
||||
|
||||
currentBatch++;
|
||||
}
|
||||
|
||||
synced += currentBatch;
|
||||
if (currentBatch < pageSize)
|
||||
{
|
||||
break;
|
||||
}
|
||||
pageNo++;
|
||||
}
|
||||
|
||||
return synced;
|
||||
}
|
||||
|
||||
private static string? GetString(JsonElement row, string propertyName)
|
||||
{
|
||||
if (!row.TryGetProperty(propertyName, out var el))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (el.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return el.GetString();
|
||||
}
|
||||
return el.ToString();
|
||||
}
|
||||
|
||||
private static int? GetInt(JsonElement row, string propertyName)
|
||||
{
|
||||
if (!row.TryGetProperty(propertyName, out var el))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (el.ValueKind == JsonValueKind.Number && el.TryGetInt32(out var intVal))
|
||||
{
|
||||
return intVal;
|
||||
}
|
||||
if (el.ValueKind == JsonValueKind.String && int.TryParse(el.GetString(), out var intStr))
|
||||
{
|
||||
return intStr;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static DateTime? GetDateTime(JsonElement row, string propertyName)
|
||||
{
|
||||
if (!row.TryGetProperty(propertyName, out var el))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (el.ValueKind == JsonValueKind.String && DateTime.TryParse(el.GetString(), out var dt))
|
||||
{
|
||||
return dt;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using System.Net.Http;
|
||||
|
||||
namespace YY.Admin.Services.Service.Jeecg;
|
||||
|
||||
/// <summary>
|
||||
/// Jeecg 后端访问网关:
|
||||
/// 1. 统一封装 HTTP 调用;
|
||||
/// 2. 统一封装 WebSocket 双向连接;
|
||||
/// 3. 作为后续 Jeecg 集成功能的统一入口。
|
||||
/// </summary>
|
||||
public interface IJeecgBackendGateway
|
||||
{
|
||||
/// <summary>
|
||||
/// 统一执行 Jeecg GET 请求(自动拼接 BaseUrl)。
|
||||
/// </summary>
|
||||
Task<HttpResponseMessage> ExecuteGetAsync(
|
||||
string relativeOrAbsoluteUrl,
|
||||
Dictionary<string, string>? headers = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 统一执行 Jeecg GET 请求并返回文本。
|
||||
/// </summary>
|
||||
Task<string?> ExecuteGetStringAsync(
|
||||
string relativeOrAbsoluteUrl,
|
||||
Dictionary<string, string>? headers = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 启动 Jeecg WebSocket 双向连接循环(自动重连)。
|
||||
/// </summary>
|
||||
Task RunWebSocketLoopAsync(
|
||||
Func<string, Task> onMessage,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// 发送一条 WebSocket 消息(连接可用时)。
|
||||
/// </summary>
|
||||
Task<bool> SendWebSocketMessageAsync(string message, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 单次 WebSocket 上报(临时连接,适用于登录页等未常驻连接场景)。
|
||||
/// </summary>
|
||||
Task<bool> SendWebSocketOneShotAsync(string message, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace YY.Admin.Services.Service.Jeecg;
|
||||
|
||||
/// <summary>
|
||||
/// Jeecg 登录日志上报服务(WebSocket + HTTP + 本地离线队列)。
|
||||
/// </summary>
|
||||
public interface IJeecgLoginLogReportService
|
||||
{
|
||||
/// <summary>
|
||||
/// 启动后台离线日志补传循环(可重复调用,内部幂等)。
|
||||
/// </summary>
|
||||
void StartBackgroundSync();
|
||||
|
||||
/// <summary>
|
||||
/// 上报一次登录日志;网络不可达时自动落本地,联网后自动补传。
|
||||
/// </summary>
|
||||
Task ReportLoginAsync(string account, bool success, string message, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 上报通用日志(操作/异常/告警等),自动走 WS/HTTP/本地队列兜底。
|
||||
/// </summary>
|
||||
Task ReportLogAsync(
|
||||
string category,
|
||||
string message,
|
||||
string? account = null,
|
||||
bool? success = null,
|
||||
string? exception = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace YY.Admin.Services.Service.Jeecg
|
||||
{
|
||||
/// <summary>
|
||||
/// Jeecg 用户镜像后台同步协调器:统一设备通道(STOMP)信号 + Outbox 拉取,不再使用独立 Jeecg WebSocket 收包循环。
|
||||
/// </summary>
|
||||
public interface IJeecgUserSyncCoordinator
|
||||
{
|
||||
/// <summary>
|
||||
/// 启动后台同步(主窗口登录成功后调用)
|
||||
/// </summary>
|
||||
void Start();
|
||||
|
||||
/// <summary>
|
||||
/// 停止后台同步与 STOMP 信号订阅(登出或关闭主窗口时调用)
|
||||
/// </summary>
|
||||
void Stop();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using System.Net.Http;
|
||||
using System.Net.WebSockets;
|
||||
using System.Text;
|
||||
using System.IO;
|
||||
|
||||
namespace YY.Admin.Services.Service.Jeecg;
|
||||
|
||||
/// <summary>
|
||||
/// Jeecg 后端网关实现。
|
||||
/// </summary>
|
||||
public class JeecgBackendGateway : IJeecgBackendGateway, ISingletonDependency
|
||||
{
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILoggerService _logger;
|
||||
private readonly SemaphoreSlim _wsSendLock = new(1, 1);
|
||||
private readonly object _wsStateLock = new();
|
||||
private ClientWebSocket? _activeWebSocket;
|
||||
|
||||
public JeecgBackendGateway(
|
||||
IConfiguration configuration,
|
||||
HttpClient httpClient,
|
||||
ILoggerService logger)
|
||||
{
|
||||
_configuration = configuration;
|
||||
_httpClient = httpClient;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<HttpResponseMessage> ExecuteGetAsync(
|
||||
string relativeOrAbsoluteUrl,
|
||||
Dictionary<string, string>? headers = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var requestUrl = BuildUrl(relativeOrAbsoluteUrl);
|
||||
using var req = new HttpRequestMessage(HttpMethod.Get, requestUrl);
|
||||
if (headers != null)
|
||||
{
|
||||
foreach (var kv in headers)
|
||||
{
|
||||
req.Headers.TryAddWithoutValidation(kv.Key, kv.Value);
|
||||
}
|
||||
}
|
||||
|
||||
return await _httpClient.SendAsync(req, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<string?> ExecuteGetStringAsync(
|
||||
string relativeOrAbsoluteUrl,
|
||||
Dictionary<string, string>? headers = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var resp = await ExecuteGetAsync(relativeOrAbsoluteUrl, headers, cancellationToken);
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return await resp.Content.ReadAsStringAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task RunWebSocketLoopAsync(
|
||||
Func<string, Task> onMessage,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var wsUrl = ResolveWebSocketUrl();
|
||||
_logger.Information($"Jeecg WebSocket 解析地址: {wsUrl}");
|
||||
if (string.IsNullOrWhiteSpace(wsUrl))
|
||||
{
|
||||
_logger.Warning("Jeecg WebSocket 未启动:解析地址为空");
|
||||
return;
|
||||
}
|
||||
|
||||
var backoffSeconds = 5;
|
||||
var buffer = new byte[8192];
|
||||
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
using var ws = new ClientWebSocket();
|
||||
try
|
||||
{
|
||||
ws.Options.KeepAliveInterval = TimeSpan.FromSeconds(10);
|
||||
await ws.ConnectAsync(new Uri(wsUrl), cancellationToken);
|
||||
lock (_wsStateLock)
|
||||
{
|
||||
_activeWebSocket = ws;
|
||||
}
|
||||
backoffSeconds = 5;
|
||||
_logger.Information($"Jeecg WebSocket 已连接: {wsUrl}");
|
||||
using var heartbeatCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
var lastReceiveTicks = DateTime.UtcNow.Ticks;
|
||||
_ = Task.Run(() => HeartbeatLoopAsync(ws, heartbeatCts.Token), heartbeatCts.Token);
|
||||
var inactivitySeconds = _configuration.GetValue("JeecgIntegration:WebSocketInactivityReconnectSeconds", 0);
|
||||
CancellationTokenSource? inactivityCts = null;
|
||||
if (inactivitySeconds > 0)
|
||||
{
|
||||
inactivityCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
_ = Task.Run(() => InactivityReconnectLoopAsync(ws, () => Interlocked.Read(ref lastReceiveTicks), inactivityCts.Token), inactivityCts.Token);
|
||||
}
|
||||
|
||||
while (ws.State == WebSocketState.Open && !cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
using var ms = new MemoryStream();
|
||||
WebSocketReceiveResult result;
|
||||
do
|
||||
{
|
||||
var seg = new ArraySegment<byte>(buffer);
|
||||
result = await ws.ReceiveAsync(seg, cancellationToken);
|
||||
_logger.Information($"Jeecg WebSocket 收帧: type={result.MessageType}, count={result.Count}, end={result.EndOfMessage}, state={ws.State}");
|
||||
if (result.MessageType == WebSocketMessageType.Close)
|
||||
{
|
||||
_logger.Warning($"Jeecg WebSocket 收到关闭帧: closeStatus={result.CloseStatus}, desc={result.CloseStatusDescription}");
|
||||
break;
|
||||
}
|
||||
ms.Write(buffer, 0, result.Count);
|
||||
} while (!result.EndOfMessage);
|
||||
|
||||
if (result.MessageType == WebSocketMessageType.Close)
|
||||
{
|
||||
_logger.Warning("Jeecg WebSocket 接收循环检测到关闭帧,准备重连");
|
||||
break;
|
||||
}
|
||||
|
||||
var payload = Encoding.UTF8.GetString(ms.ToArray());
|
||||
Interlocked.Exchange(ref lastReceiveTicks, DateTime.UtcNow.Ticks);
|
||||
_logger.Information($"Jeecg WebSocket 收到原始消息: {payload}");
|
||||
await onMessage(payload);
|
||||
}
|
||||
heartbeatCts.Cancel();
|
||||
inactivityCts?.Cancel();
|
||||
_logger.Warning($"Jeecg WebSocket 接收循环退出,当前状态={ws.State}");
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.Warning("Jeecg WebSocket 接收循环取消");
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"Jeecg WebSocket 断开,{backoffSeconds} 秒后重连。地址: {wsUrl},异常: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
lock (_wsStateLock)
|
||||
{
|
||||
if (ReferenceEquals(_activeWebSocket, ws))
|
||||
{
|
||||
_activeWebSocket = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(Math.Min(backoffSeconds, 120)), cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
backoffSeconds = Math.Min(backoffSeconds * 2, 120);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HeartbeatLoopAsync(ClientWebSocket ws, CancellationToken cancellationToken)
|
||||
{
|
||||
var payload = Encoding.UTF8.GetBytes("ping");
|
||||
while (!cancellationToken.IsCancellationRequested && ws.State == WebSocketState.Open)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(12), cancellationToken);
|
||||
if (ws.State != WebSocketState.Open)
|
||||
{
|
||||
break;
|
||||
}
|
||||
await ws.SendAsync(new ArraySegment<byte>(payload), WebSocketMessageType.Text, true, cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"Jeecg WebSocket 心跳发送失败: {ex.Message}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task InactivityReconnectLoopAsync(ClientWebSocket ws, Func<long> getLastReceiveTicks, CancellationToken cancellationToken)
|
||||
{
|
||||
var inactivitySeconds = _configuration.GetValue("JeecgIntegration:WebSocketInactivityReconnectSeconds", 0);
|
||||
if (inactivitySeconds <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
while (!cancellationToken.IsCancellationRequested && ws.State == WebSocketState.Open)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(2), cancellationToken);
|
||||
if (ws.State != WebSocketState.Open)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var lastReceiveUtc = new DateTime(getLastReceiveTicks(), DateTimeKind.Utc);
|
||||
var idleSeconds = (DateTime.UtcNow - lastReceiveUtc).TotalSeconds;
|
||||
if (idleSeconds < inactivitySeconds)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.Warning($"Jeecg WebSocket 超过 {Math.Round(idleSeconds)} 秒未收到任何消息,主动重连");
|
||||
ws.Abort();
|
||||
break;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"Jeecg WebSocket 空闲检测异常: {ex.Message}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> SendWebSocketMessageAsync(string message, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ClientWebSocket? socket;
|
||||
lock (_wsStateLock)
|
||||
{
|
||||
socket = _activeWebSocket;
|
||||
}
|
||||
if (socket == null || socket.State != WebSocketState.Open)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var bytes = Encoding.UTF8.GetBytes(message);
|
||||
var seg = new ArraySegment<byte>(bytes);
|
||||
await _wsSendLock.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
await socket.SendAsync(seg, WebSocketMessageType.Text, true, cancellationToken);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_wsSendLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> SendWebSocketOneShotAsync(string message, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var wsUrl = ResolveWebSocketUrl();
|
||||
if (string.IsNullOrWhiteSpace(wsUrl))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
using var ws = new ClientWebSocket();
|
||||
try
|
||||
{
|
||||
await ws.ConnectAsync(new Uri(wsUrl), cancellationToken);
|
||||
var bytes = Encoding.UTF8.GetBytes(message);
|
||||
var seg = new ArraySegment<byte>(bytes);
|
||||
await ws.SendAsync(seg, WebSocketMessageType.Text, true, cancellationToken);
|
||||
await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "done", cancellationToken);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"Jeecg WebSocket 单次上报失败: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private string BuildUrl(string relativeOrAbsoluteUrl)
|
||||
{
|
||||
if (Uri.TryCreate(relativeOrAbsoluteUrl, UriKind.Absolute, out _))
|
||||
{
|
||||
return relativeOrAbsoluteUrl;
|
||||
}
|
||||
|
||||
var baseUrl = _configuration.GetValue<string>("JeecgIntegration:BaseUrl")?.TrimEnd('/');
|
||||
if (string.IsNullOrWhiteSpace(baseUrl))
|
||||
{
|
||||
return relativeOrAbsoluteUrl;
|
||||
}
|
||||
var path = relativeOrAbsoluteUrl.StartsWith("/") ? relativeOrAbsoluteUrl : "/" + relativeOrAbsoluteUrl;
|
||||
return $"{baseUrl}{path}";
|
||||
}
|
||||
|
||||
private string ResolveWebSocketUrl()
|
||||
{
|
||||
var anonymousMode = _configuration.GetValue("JeecgIntegration:AnonymousMode", true);
|
||||
var configUrl = _configuration.GetValue<string>("JeecgIntegration:WebSocketUrl");
|
||||
if (!string.IsNullOrWhiteSpace(configUrl))
|
||||
{
|
||||
return NormalizeWebSocketUrl(configUrl.Trim(), anonymousMode);
|
||||
}
|
||||
|
||||
var baseUrl = _configuration.GetValue<string>("JeecgIntegration:BaseUrl")?.TrimEnd('/');
|
||||
if (string.IsNullOrWhiteSpace(baseUrl))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var wsPath = _configuration.GetValue<string>("JeecgIntegration:WebSocketPath");
|
||||
if (string.IsNullOrWhiteSpace(wsPath))
|
||||
{
|
||||
wsPath = "/websocket/scada-sync";
|
||||
}
|
||||
else if (!wsPath.StartsWith("/"))
|
||||
{
|
||||
wsPath = "/" + wsPath;
|
||||
}
|
||||
|
||||
// 默认从 BaseUrl + WebSocketPath 推导地址,避免只连到根路径导致握手失败
|
||||
if (baseUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return NormalizeWebSocketUrl("wss://" + baseUrl["https://".Length..] + wsPath, anonymousMode);
|
||||
}
|
||||
if (baseUrl.StartsWith("http://", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return NormalizeWebSocketUrl("ws://" + baseUrl["http://".Length..] + wsPath, anonymousMode);
|
||||
}
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
private static string NormalizeWebSocketUrl(string wsUrl, bool anonymousMode)
|
||||
{
|
||||
if (!anonymousMode)
|
||||
{
|
||||
return wsUrl;
|
||||
}
|
||||
if (wsUrl.Contains("/ws/device/websocket", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return wsUrl.Replace("/ws/device/websocket", "/websocket/scada-sync", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
return wsUrl;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using System.Net.Http;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace YY.Admin.Services.Service.Jeecg;
|
||||
|
||||
/// <summary>
|
||||
/// 登录日志上报:
|
||||
/// 1) WebSocket 优先;
|
||||
/// 2) 失败自动 HTTP 兜底;
|
||||
/// 3) 仍失败则写本地队列,后台自动补传。
|
||||
/// </summary>
|
||||
public class JeecgLoginLogReportService : IJeecgLoginLogReportService, IClientLogReportSink, ISingletonDependency
|
||||
{
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly IJeecgBackendGateway _jeecgBackendGateway;
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly SemaphoreSlim _queueLock = new(1, 1);
|
||||
private readonly CancellationTokenSource _syncCts = new();
|
||||
private int _started;
|
||||
|
||||
private sealed class LoginLogQueueItem
|
||||
{
|
||||
public string Id { get; set; } = Guid.NewGuid().ToString("N");
|
||||
public string Category { get; set; } = "LOGIN";
|
||||
public string Account { get; set; } = string.Empty;
|
||||
public bool? Success { get; set; }
|
||||
public string Message { get; set; } = string.Empty;
|
||||
public string? Exception { get; set; }
|
||||
public int LogType { get; set; } = 1;
|
||||
public int OperateType { get; set; } = 5;
|
||||
public string Method { get; set; } = "LOGIN";
|
||||
public string RequestUrl { get; set; } = "/desktop/log";
|
||||
public long Timestamp { get; set; } = DateTimeOffset.Now.ToUnixTimeMilliseconds();
|
||||
public string ClientType { get; set; } = "gkj";
|
||||
}
|
||||
|
||||
public JeecgLoginLogReportService(
|
||||
IConfiguration configuration,
|
||||
IJeecgBackendGateway jeecgBackendGateway,
|
||||
HttpClient httpClient)
|
||||
{
|
||||
_configuration = configuration;
|
||||
_jeecgBackendGateway = jeecgBackendGateway;
|
||||
_httpClient = httpClient;
|
||||
}
|
||||
|
||||
public void StartBackgroundSync()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _started, 1) == 1)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_ = Task.Run(() => BackgroundSyncLoopAsync(_syncCts.Token), _syncCts.Token);
|
||||
}
|
||||
|
||||
public async Task ReportLoginAsync(string account, bool success, string message, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await ReportLogAsync("LOGIN", message, account, success, null, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task ReportLogAsync(
|
||||
string category,
|
||||
string message,
|
||||
string? account = null,
|
||||
bool? success = null,
|
||||
string? exception = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var normalizedCategory = string.IsNullOrWhiteSpace(category) ? "OPERATION" : category.Trim().ToUpperInvariant();
|
||||
var item = BuildQueueItem(normalizedCategory, account, success, message, exception);
|
||||
if (await TrySendToBackendAsync(item, cancellationToken))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await EnqueueAsync(item, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task BackgroundSyncLoopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await FlushQueueAsync(cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"日志离线补传失败: {ex.Message}");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> TrySendToBackendAsync(LoginLogQueueItem item, CancellationToken cancellationToken)
|
||||
{
|
||||
var payload = new
|
||||
{
|
||||
cmd = "SCADA_LOG",
|
||||
category = item.Category,
|
||||
account = item.Account,
|
||||
success = item.Success,
|
||||
message = item.Message,
|
||||
exception = item.Exception,
|
||||
logType = item.LogType,
|
||||
operateType = item.OperateType,
|
||||
method = item.Method,
|
||||
requestUrl = item.RequestUrl,
|
||||
clientType = item.ClientType,
|
||||
timestamp = item.Timestamp
|
||||
};
|
||||
var json = JsonSerializer.Serialize(payload);
|
||||
|
||||
if (await _jeecgBackendGateway.SendWebSocketOneShotAsync(json, cancellationToken))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var baseUrl = _configuration.GetValue<string>("JeecgIntegration:BaseUrl")?.TrimEnd('/');
|
||||
if (string.IsNullOrWhiteSpace(baseUrl))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var url = $"{baseUrl}/sys/log/scada/addLoginLog";
|
||||
using var req = new HttpRequestMessage(HttpMethod.Post, url)
|
||||
{
|
||||
Content = new StringContent(json, Encoding.UTF8, "application/json")
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
using var resp = await _httpClient.SendAsync(req, cancellationToken);
|
||||
return resp.IsSuccessStatusCode;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task EnqueueAsync(LoginLogQueueItem item, CancellationToken cancellationToken)
|
||||
{
|
||||
await _queueLock.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
var lines = new List<string>();
|
||||
var path = GetQueuePath();
|
||||
if (File.Exists(path))
|
||||
{
|
||||
lines = File.ReadAllLines(path).Where(l => !string.IsNullOrWhiteSpace(l)).ToList();
|
||||
}
|
||||
lines.Add(JsonSerializer.Serialize(item));
|
||||
EnsureQueueDir(path);
|
||||
File.WriteAllLines(path, lines);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_queueLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task FlushQueueAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await _queueLock.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
var path = GetQueuePath();
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var lines = File.ReadAllLines(path).Where(l => !string.IsNullOrWhiteSpace(l)).ToList();
|
||||
if (lines.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var remain = new List<string>();
|
||||
foreach (var line in lines)
|
||||
{
|
||||
LoginLogQueueItem? item = null;
|
||||
try
|
||||
{
|
||||
item = JsonSerializer.Deserialize<LoginLogQueueItem>(line);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 解析失败的数据直接丢弃,避免阻塞后续
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item == null || !await TrySendToBackendAsync(item, cancellationToken))
|
||||
{
|
||||
remain.Add(line);
|
||||
}
|
||||
}
|
||||
|
||||
if (remain.Count == 0)
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
else
|
||||
{
|
||||
File.WriteAllLines(path, remain);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_queueLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetQueuePath()
|
||||
{
|
||||
return Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "AppSettings", "offline-scada-log-queue.jsonl");
|
||||
}
|
||||
|
||||
private static void EnsureQueueDir(string path)
|
||||
{
|
||||
var dir = Path.GetDirectoryName(path);
|
||||
if (!string.IsNullOrWhiteSpace(dir) && !Directory.Exists(dir))
|
||||
{
|
||||
Directory.CreateDirectory(dir);
|
||||
}
|
||||
}
|
||||
|
||||
private static LoginLogQueueItem BuildQueueItem(
|
||||
string category,
|
||||
string? account,
|
||||
bool? success,
|
||||
string? message,
|
||||
string? exception)
|
||||
{
|
||||
var item = new LoginLogQueueItem
|
||||
{
|
||||
Category = category,
|
||||
Account = account ?? string.Empty,
|
||||
Success = success,
|
||||
Message = message ?? string.Empty,
|
||||
Exception = exception
|
||||
};
|
||||
|
||||
switch (category)
|
||||
{
|
||||
case "LOGIN":
|
||||
item.LogType = 1;
|
||||
item.OperateType = 1;
|
||||
item.Method = "LOGIN";
|
||||
item.RequestUrl = "/desktop/login";
|
||||
break;
|
||||
case "EXCEPTION":
|
||||
item.LogType = 2;
|
||||
item.OperateType = 5;
|
||||
item.Method = "ERROR";
|
||||
item.RequestUrl = "/desktop/exception";
|
||||
break;
|
||||
case "WARNING":
|
||||
item.LogType = 2;
|
||||
item.OperateType = 4;
|
||||
item.Method = "WARNING";
|
||||
item.RequestUrl = "/desktop/warning";
|
||||
break;
|
||||
default:
|
||||
item.LogType = 1;
|
||||
item.OperateType = 5;
|
||||
item.Method = "OPERATION";
|
||||
item.RequestUrl = "/desktop/operation";
|
||||
break;
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace YY.Admin.Services.Service.Jeecg
|
||||
{
|
||||
/// <summary>
|
||||
/// Jeecg 同步本地状态(工控机断网续传水位)
|
||||
/// </summary>
|
||||
public class JeecgSyncState
|
||||
{
|
||||
/// <summary>
|
||||
/// 同步水位(UTC):标准列表作 updateTime_begin;SCADA 作 updatedAfter(与接口文档游标一致)
|
||||
/// </summary>
|
||||
[JsonPropertyName("lastUserListSyncUtc")]
|
||||
public DateTime? LastUserListSyncUtc { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace YY.Admin.Services.Service.Jeecg
|
||||
{
|
||||
/// <summary>
|
||||
/// 读写本地 Jeecg 同步状态文件(与 appsettings 同目录下的 Configuration)
|
||||
/// </summary>
|
||||
public class JeecgSyncStateStore
|
||||
{
|
||||
private readonly string _filePath;
|
||||
|
||||
public JeecgSyncStateStore()
|
||||
{
|
||||
var dir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Configuration");
|
||||
_filePath = Path.Combine(dir, "jeecg-sync-state.json");
|
||||
}
|
||||
|
||||
public JeecgSyncState Load()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(_filePath))
|
||||
{
|
||||
return new JeecgSyncState();
|
||||
}
|
||||
|
||||
var json = File.ReadAllText(_filePath);
|
||||
return JsonSerializer.Deserialize<JeecgSyncState>(json) ?? new JeecgSyncState();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new JeecgSyncState();
|
||||
}
|
||||
}
|
||||
|
||||
public void Save(JeecgSyncState state)
|
||||
{
|
||||
var dir = Path.GetDirectoryName(_filePath);
|
||||
if (!string.IsNullOrEmpty(dir))
|
||||
{
|
||||
Directory.CreateDirectory(dir);
|
||||
}
|
||||
|
||||
var json = JsonSerializer.Serialize(state, new JsonSerializerOptions { WriteIndented = true });
|
||||
File.WriteAllText(_filePath, json);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using YY.Admin.Core;
|
||||
using YY.Admin.Core.Services;
|
||||
using YY.Admin.Services.Service.Auth;
|
||||
|
||||
namespace YY.Admin.Services.Service.Jeecg;
|
||||
|
||||
/// <summary>
|
||||
/// Outbox 消费端:执行 SCADA 全量拉取并写入 jeecg_sys_user(与设备模块 HTTP 幂等上报并列的第二条 REST 能力)。
|
||||
/// </summary>
|
||||
public class JeecgUserMirrorPullHandler : IJeecgUserMirrorPullHandler, ISingletonDependency
|
||||
{
|
||||
private readonly ISysAuthService _authService;
|
||||
|
||||
public JeecgUserMirrorPullHandler(ISysAuthService authService)
|
||||
{
|
||||
_authService = authService;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> ExecutePullAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return _authService.TryBackgroundSyncJeecgUsersAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Prism.Events;
|
||||
using System.Text.Json;
|
||||
using YY.Admin.Core;
|
||||
using YY.Admin.Core.Events;
|
||||
using YY.Admin.Core.Services;
|
||||
using YY.Admin.Core.Sync;
|
||||
|
||||
namespace YY.Admin.Services.Service.Jeecg;
|
||||
|
||||
/// <summary>
|
||||
/// 用户镜像同步:统一走设备同步规范线路(STOMP 收信号 → Outbox → REST 拉取 SCADA),不再使用独立 Jeecg 原生 WebSocket 收包循环。
|
||||
/// </summary>
|
||||
public class JeecgUserSyncCoordinator : IJeecgUserSyncCoordinator, ISingletonDependency
|
||||
{
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly IEventAggregator _eventAggregator;
|
||||
private readonly IJeecgUserMirrorPullOutbox _mirrorOutbox;
|
||||
private readonly ILoggerService _logger;
|
||||
private CancellationTokenSource? _cts;
|
||||
private readonly object _lifecycleLock = new();
|
||||
private SubscriptionToken? _remoteCommandSubscription;
|
||||
|
||||
public JeecgUserSyncCoordinator(
|
||||
IConfiguration configuration,
|
||||
IEventAggregator eventAggregator,
|
||||
IJeecgUserMirrorPullOutbox mirrorOutbox,
|
||||
ILoggerService logger)
|
||||
{
|
||||
_configuration = configuration;
|
||||
_eventAggregator = eventAggregator;
|
||||
_mirrorOutbox = mirrorOutbox;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Start()
|
||||
{
|
||||
var enabled = _configuration.GetValue("JeecgIntegration:Enabled", false);
|
||||
var baseUrl = _configuration.GetValue<string>("JeecgIntegration:BaseUrl") ?? string.Empty;
|
||||
var stompPath = "/ws/device/websocket";
|
||||
_logger.Information($"Jeecg用户同步协调器启动(统一设备通道),Enabled={enabled}, BaseUrl={baseUrl}, Stomp={stompPath}");
|
||||
|
||||
if (!enabled)
|
||||
{
|
||||
_logger.Warning("Jeecg用户同步协调器未启动:JeecgIntegration:Enabled=false");
|
||||
return;
|
||||
}
|
||||
|
||||
CancellationToken token;
|
||||
lock (_lifecycleLock)
|
||||
{
|
||||
CancelAndDisposeCts();
|
||||
UnsubscribeRemoteCommand();
|
||||
_remoteCommandSubscription = _eventAggregator.GetEvent<RemoteCommandReceivedEvent>().Subscribe(OnRemoteCommand, ThreadOption.BackgroundThread);
|
||||
|
||||
_cts = new CancellationTokenSource();
|
||||
token = _cts.Token;
|
||||
}
|
||||
|
||||
// 进入主窗口后稍延迟再入队一次全量拉取,避免与登录同步抢带宽
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(3000, token).ConfigureAwait(false);
|
||||
await _mirrorOutbox.EnqueuePullAsync(JeecgUserMirrorOutbox.EventBoot, null, token).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// 忽略
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"Jeecg 启动后入队同步失败: {ex.Message}");
|
||||
}
|
||||
}, token);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Stop()
|
||||
{
|
||||
lock (_lifecycleLock)
|
||||
{
|
||||
UnsubscribeRemoteCommand();
|
||||
CancelAndDisposeCts();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnRemoteCommand(RemoteCommandPayload payload)
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = payload.CommandJson ?? string.Empty;
|
||||
if (!ShouldTriggerUserSync(json))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.Information($"收到设备统一通道(STOMP)用户变更信号,入队 Outbox: {json}");
|
||||
_ = _mirrorOutbox.EnqueuePullAsync(JeecgUserMirrorOutbox.EventSignal, json, CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"处理 STOMP 用户变更信号失败: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void UnsubscribeRemoteCommand()
|
||||
{
|
||||
if (_remoteCommandSubscription != null)
|
||||
{
|
||||
_eventAggregator.GetEvent<RemoteCommandReceivedEvent>().Unsubscribe(_remoteCommandSubscription);
|
||||
_remoteCommandSubscription = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void CancelAndDisposeCts()
|
||||
{
|
||||
try
|
||||
{
|
||||
_cts?.Cancel();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 忽略
|
||||
}
|
||||
finally
|
||||
{
|
||||
_cts?.Dispose();
|
||||
_cts = null;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ShouldTriggerUserSync(string message)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(message))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(message);
|
||||
var root = doc.RootElement;
|
||||
if (TryMatchCmd(root))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
// 设备模块 REST 下发的 commandJson 包裹
|
||||
if (root.TryGetProperty("commandJson", out var innerEl) && innerEl.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var rawInner = innerEl.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(rawInner))
|
||||
{
|
||||
using var innerDoc = JsonDocument.Parse(rawInner);
|
||||
return TryMatchCmd(innerDoc.RootElement);
|
||||
}
|
||||
}
|
||||
if (root.TryGetProperty("message", out var innerMessage)
|
||||
&& innerMessage.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var rawInner = innerMessage.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(rawInner))
|
||||
{
|
||||
using var innerDoc = JsonDocument.Parse(rawInner);
|
||||
return TryMatchCmd(innerDoc.RootElement);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryMatchCmd(JsonElement element)
|
||||
{
|
||||
if (element.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (!element.TryGetProperty("cmd", out var cmd))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
var cmdValue = cmd.GetString();
|
||||
return string.Equals(cmdValue, "SCADA_USER_CHANGED", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(cmdValue, "SCADA_USERS_CHANGED", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
130
yy-admin-master/YY.Admin.Services/Service/Menu/Dto/MenuOutput.cs
Normal file
130
yy-admin-master/YY.Admin.Services/Service/Menu/Dto/MenuOutput.cs
Normal file
@@ -0,0 +1,130 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace YY.Admin.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// 系统菜单返回结果
|
||||
/// </summary>
|
||||
public class MenuOutput
|
||||
{
|
||||
/// <summary>
|
||||
/// Id
|
||||
/// </summary>
|
||||
public long Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 父Id
|
||||
/// </summary>
|
||||
public long Pid { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 菜单类型(0目录 1菜单 2按钮)
|
||||
/// </summary>
|
||||
public MenuTypeEnum Type { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 名称
|
||||
/// </summary>
|
||||
public string Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 路由地址
|
||||
/// </summary>
|
||||
public string Path { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 组件路径
|
||||
/// </summary>
|
||||
public string Component { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 权限标识
|
||||
/// </summary>
|
||||
public string Permission { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 重定向
|
||||
/// </summary>
|
||||
public string Redirect { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 排序
|
||||
/// </summary>
|
||||
public int OrderNo { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态
|
||||
/// </summary>
|
||||
public StatusEnum Status { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 备注
|
||||
/// </summary>
|
||||
public string Remark { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 创建时间
|
||||
/// </summary>
|
||||
public virtual DateTime CreateTime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 更新时间
|
||||
/// </summary>
|
||||
public virtual DateTime UpdateTime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 创建者姓名
|
||||
/// </summary>
|
||||
public virtual string CreateUserName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 修改者姓名
|
||||
/// </summary>
|
||||
public virtual string UpdateUserName { get; set; }
|
||||
|
||||
#region 菜单Meta
|
||||
/// <summary>
|
||||
/// 标题
|
||||
/// </summary>
|
||||
public string Title { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 图标
|
||||
/// </summary>
|
||||
public string Icon { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否内嵌
|
||||
/// </summary>
|
||||
public bool IsIframe { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 外链链接
|
||||
/// </summary>
|
||||
public string IsLink { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否隐藏
|
||||
/// </summary>
|
||||
public bool IsHide { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否缓存
|
||||
/// </summary>
|
||||
public bool IsKeepAlive { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否固定
|
||||
/// </summary>
|
||||
public bool IsAffix { get; set; }
|
||||
#endregion
|
||||
/// <summary>
|
||||
/// 菜单子项
|
||||
/// </summary>
|
||||
public List<MenuOutput> Children { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace YY.Admin.Services.Service.Menu
|
||||
{
|
||||
public interface ISysMenuService
|
||||
{
|
||||
Task<List<MenuOutput>> GetLoginMenuTree();
|
||||
}
|
||||
}
|
||||
136
yy-admin-master/YY.Admin.Services/Service/Menu/SysMenuService.cs
Normal file
136
yy-admin-master/YY.Admin.Services/Service/Menu/SysMenuService.cs
Normal file
@@ -0,0 +1,136 @@
|
||||
using Mapster;
|
||||
using SqlSugar;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using YY.Admin.Core.Session;
|
||||
using YY.Admin.Services.Service.Role;
|
||||
using YY.Admin.Services.Service.User;
|
||||
|
||||
namespace YY.Admin.Services.Service.Menu
|
||||
{
|
||||
public class SysMenuService : ISysMenuService, ISingletonDependency
|
||||
{
|
||||
private readonly ISqlSugarClient _dbContext;
|
||||
private readonly SysUserRoleService _sysUserRoleService;
|
||||
private readonly SysRoleMenuService _sysRoleMenuService;
|
||||
public SysMenuService(
|
||||
ISqlSugarClient context,
|
||||
SysRoleMenuService sysRoleMenuService,
|
||||
SysUserRoleService sysUserRoleService) {
|
||||
_dbContext=context;
|
||||
_sysUserRoleService=sysUserRoleService;
|
||||
_sysRoleMenuService=sysRoleMenuService;
|
||||
}
|
||||
/// <summary>
|
||||
/// 获取登录菜单树
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public async Task<List<MenuOutput>> GetLoginMenuTree()
|
||||
{
|
||||
var currentUser = AppSession.CurrentUser;
|
||||
if (currentUser == null)
|
||||
{
|
||||
return new List<MenuOutput>();
|
||||
}
|
||||
|
||||
var tenantId = currentUser.TenantId ?? 0;
|
||||
var (query, _) = GetSugarQueryableAndTenantId(tenantId);
|
||||
if (currentUser.IsSuperAdmin || currentUser.IsSysAdmin)
|
||||
{
|
||||
var menuList = await query.Where(u => u.Type != MenuTypeEnum.Btn && u.Status == StatusEnum.Enable)
|
||||
.OrderBy(u => new { u.OrderNo, u.Id })
|
||||
.ToTreeAsync(u => u.Children, u => u.Pid, 0);
|
||||
return menuList.Adapt<List<MenuOutput>>();
|
||||
}
|
||||
var menuIdList = await GetMenuIdList();
|
||||
if (menuIdList == null || menuIdList.Count == 0)
|
||||
{
|
||||
// Jeecg自动建档用户可能暂未分配本地角色,这里回退显示基础菜单
|
||||
var fallbackMenus = await GetFallbackMenuTreeAsync();
|
||||
return fallbackMenus.Adapt<List<MenuOutput>>();
|
||||
}
|
||||
|
||||
var menuTree = await query.Where(u => u.Type != MenuTypeEnum.Btn && u.Status == StatusEnum.Enable)
|
||||
.OrderBy(u => new { u.OrderNo, u.Id }).ToTreeAsync(u => u.Children, u => u.Pid, 0, menuIdList.Select(d => (object)d).ToArray());
|
||||
|
||||
// 角色或租户菜单未配置时,避免左侧功能列表全空白
|
||||
if (menuTree == null || menuTree.Count == 0)
|
||||
{
|
||||
menuTree = await GetFallbackMenuTreeAsync();
|
||||
}
|
||||
|
||||
return menuTree.Adapt<List<MenuOutput>>();
|
||||
}
|
||||
/// <summary>
|
||||
/// 获取当前用户菜单Id集合
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public async Task<List<long>> GetMenuIdList()
|
||||
{
|
||||
var currentUser = AppSession.CurrentUser;
|
||||
if (currentUser == null)
|
||||
{
|
||||
return new List<long>();
|
||||
}
|
||||
|
||||
var roleIdList = await _sysUserRoleService.GetUserRoleIdList(currentUser.Id);
|
||||
return await _sysRoleMenuService.GetRoleMenuIdList(roleIdList);
|
||||
}
|
||||
/// <summary>
|
||||
/// 根据租户id获取构建菜单联表查询实例
|
||||
/// </summary>
|
||||
/// <param name="tenantId"></param>
|
||||
/// <returns></returns>
|
||||
public (ISugarQueryable<SysMenu, SysTenantMenu> query, long tenantId) GetSugarQueryableAndTenantId(long tenantId)
|
||||
{
|
||||
if (!AppSession.CurrentUser!.IsSuperAdmin) tenantId = AppSession.CurrentUser.TenantId!.Value;
|
||||
|
||||
// 超管用户菜单范围:种子菜单 + 租户id菜单
|
||||
ISugarQueryable<SysMenu, SysTenantMenu> query;
|
||||
if (AppSession.CurrentUser.IsSuperAdmin)
|
||||
{
|
||||
if (tenantId <= 0)
|
||||
{
|
||||
query = _dbContext.Queryable<SysMenu>().InnerJoinIF<SysTenantMenu>(false, (u, t) => true);
|
||||
}
|
||||
else
|
||||
{
|
||||
// 指定租户的菜单
|
||||
var menuIds = _dbContext.Queryable<SysTenantMenu>().Where(u => u.TenantId == tenantId).ToList(u => u.MenuId) ?? new();
|
||||
|
||||
// 种子菜单
|
||||
//menuIds.AddRange(new SysMenuSeedData().HasData().Select(u => u.Id).ToList());
|
||||
|
||||
menuIds = menuIds.Distinct().ToList();
|
||||
query = _dbContext.Queryable<SysMenu>().InnerJoinIF<SysTenantMenu>(false, (u, t) => true).Where(u => menuIds.Contains(u.Id));
|
||||
}
|
||||
}
|
||||
else if (AppSession.CurrentUser.IsSysAdmin)
|
||||
{
|
||||
// 系统管理员直接读取全量启用菜单,不依赖租户菜单关联表
|
||||
query = _dbContext.Queryable<SysMenu>().InnerJoinIF<SysTenantMenu>(false, (u, t) => true);
|
||||
}
|
||||
else
|
||||
{
|
||||
query = _dbContext.Queryable<SysMenu>().InnerJoinIF<SysTenantMenu>(tenantId > 0, (u, t) => t.TenantId == tenantId && u.Id == t.MenuId);
|
||||
}
|
||||
|
||||
return (query, tenantId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 菜单兜底:当角色/租户未完成绑定时返回可用基础菜单,避免界面空白
|
||||
/// </summary>
|
||||
private async Task<List<SysMenu>> GetFallbackMenuTreeAsync()
|
||||
{
|
||||
return await _dbContext.Queryable<SysMenu>()
|
||||
.Where(u => u.Type != MenuTypeEnum.Btn && u.Status == StatusEnum.Enable)
|
||||
.OrderBy(u => new { u.OrderNo, u.Id })
|
||||
.ToTreeAsync(u => u.Children, u => u.Pid, 0);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace YY.Admin.Services.Service
|
||||
{
|
||||
public interface ISysOrgService
|
||||
{
|
||||
/// <summary>
|
||||
/// 根据节点Id获取子节点Id集合(包含自己)
|
||||
/// </summary>
|
||||
/// <param name="pid"></param>
|
||||
/// <returns></returns>
|
||||
Task<List<long>> GetChildIdListWithSelfById(long pid);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using SqlSugar;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace YY.Admin.Services.Service.Org
|
||||
{
|
||||
public class SysOrgService : ISysOrgService, ISingletonDependency
|
||||
{
|
||||
private readonly ISqlSugarClient _dbContext;
|
||||
public SysOrgService(ISqlSugarClient dbContext) {
|
||||
_dbContext=dbContext;
|
||||
}
|
||||
public async Task<List<long>> GetChildIdListWithSelfById(long pid)
|
||||
{
|
||||
var orgTreeList = await _dbContext.Queryable<SysOrg>().ToChildListAsync(u => u.Pid, pid, true);
|
||||
return orgTreeList.Select(u => u.Id).ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace YY.Admin.Services.Service.Role
|
||||
{
|
||||
public interface ISysRoleMenuService
|
||||
{
|
||||
/// <summary>
|
||||
/// 根据角色Id集合获取菜单Id集合
|
||||
/// </summary>
|
||||
/// <param name="roleIdList"></param>
|
||||
/// <returns></returns>
|
||||
Task<List<long>> GetRoleMenuIdList(List<long> roleIdList);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using SqlSugar;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace YY.Admin.Services.Service.Role
|
||||
{
|
||||
public class SysRoleMenuService : ISysRoleMenuService, ISingletonDependency
|
||||
{
|
||||
private readonly ISqlSugarClient _dbContext;
|
||||
public SysRoleMenuService(ISqlSugarClient context)
|
||||
{
|
||||
_dbContext = context;
|
||||
}
|
||||
public async Task<List<long>> GetRoleMenuIdList(List<long> roleIdList)
|
||||
{
|
||||
return await _dbContext.Queryable<SysRoleMenu>()
|
||||
.Where(u => roleIdList.Contains(u.RoleId))
|
||||
.Select(u => u.MenuId).ToListAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace YY.Admin.Services.Service
|
||||
{
|
||||
/// <summary>
|
||||
/// 租户分页查询参数
|
||||
/// </summary>
|
||||
public class PageTenantInput : PagedRequestBase
|
||||
{
|
||||
/// <summary>
|
||||
/// 租户名称(本地映射为Title)
|
||||
/// </summary>
|
||||
public string? Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态
|
||||
/// </summary>
|
||||
public StatusEnum? Status { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace YY.Admin.Services.Service
|
||||
{
|
||||
/// <summary>
|
||||
/// 租户列表输出
|
||||
/// </summary>
|
||||
public class TenantOutput
|
||||
{
|
||||
public long Id { get; set; }
|
||||
|
||||
public string? Title { get; set; }
|
||||
|
||||
public string? Logo { get; set; }
|
||||
|
||||
public StatusEnum Status { get; set; }
|
||||
|
||||
public DateTime CreateTime { get; set; }
|
||||
|
||||
public DateTime? UpdateTime { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace YY.Admin.Services.Service.Tenant
|
||||
{
|
||||
public interface ISysTenantSyncService
|
||||
{
|
||||
Task<SqlSugarPagedList<TenantOutput>> PageAsync(PageTenantInput input);
|
||||
|
||||
/// <summary>
|
||||
/// 从 Jeecg 后台同步租户信息到本地
|
||||
/// </summary>
|
||||
Task<int> SyncFromJeecgAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using SqlSugar;
|
||||
using System.Text.Json;
|
||||
using YY.Admin.Core.Session;
|
||||
using YY.Admin.Services.Service.Jeecg;
|
||||
|
||||
namespace YY.Admin.Services.Service.Tenant
|
||||
{
|
||||
public class SysTenantSyncService : ISysTenantSyncService, ISingletonDependency
|
||||
{
|
||||
private readonly ISqlSugarClient _dbContext;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly ISysCacheService _sysCacheService;
|
||||
private readonly IJeecgBackendGateway _jeecgGateway;
|
||||
|
||||
public SysTenantSyncService(
|
||||
ISqlSugarClient dbContext,
|
||||
IConfiguration configuration,
|
||||
ISysCacheService sysCacheService,
|
||||
IJeecgBackendGateway jeecgGateway)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
_configuration = configuration;
|
||||
_sysCacheService = sysCacheService;
|
||||
_jeecgGateway = jeecgGateway;
|
||||
}
|
||||
|
||||
public async Task<SqlSugarPagedList<TenantOutput>> PageAsync(PageTenantInput input)
|
||||
{
|
||||
var status = input.Status;
|
||||
var statusValue = status.GetValueOrDefault();
|
||||
var query = _dbContext.Queryable<SysTenant>().ClearFilter()
|
||||
.WhereIF(!string.IsNullOrWhiteSpace(input.Name), t => t.Title!.Contains(input.Name!))
|
||||
.WhereIF(status.HasValue, t => t.Status == statusValue)
|
||||
.OrderBy(t => t.Id);
|
||||
|
||||
RefAsync<int> total = 0;
|
||||
var list = await query.ToPageListAsync(input.Page, input.PageSize, total);
|
||||
var items = list.Select(t => new TenantOutput
|
||||
{
|
||||
Id = t.Id,
|
||||
Title = t.Title,
|
||||
Logo = t.Logo,
|
||||
Status = t.Status,
|
||||
CreateTime = t.CreateTime,
|
||||
UpdateTime = t.UpdateTime
|
||||
}).ToList();
|
||||
|
||||
return new SqlSugarPagedList<TenantOutput>
|
||||
{
|
||||
Page = input.Page,
|
||||
PageSize = input.PageSize,
|
||||
Total = total,
|
||||
TotalPages = input.PageSize > 0 ? (int)Math.Ceiling(total / (double)input.PageSize) : 0,
|
||||
HasNextPage = input.PageSize > 0 && input.Page < (int)Math.Ceiling(total / (double)input.PageSize),
|
||||
HasPrevPage = input.Page > 1,
|
||||
Items = items
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<int> SyncFromJeecgAsync()
|
||||
{
|
||||
var userId = AppSession.CurrentUser?.Id;
|
||||
if (!userId.HasValue || userId.Value <= 0) return 0;
|
||||
|
||||
var tokenKey = $"jeecg:token:{userId.Value}";
|
||||
var token = _sysCacheService.Get<string>(tokenKey);
|
||||
if (string.IsNullOrWhiteSpace(token)) return 0;
|
||||
|
||||
var baseUrl = _configuration.GetValue<string>("JeecgIntegration:BaseUrl")?.TrimEnd('/');
|
||||
if (string.IsNullOrWhiteSpace(baseUrl)) return 0;
|
||||
|
||||
var tenantListPath = _configuration.GetValue<string>("JeecgIntegration:TenantListPath") ?? "/sys/tenant/list";
|
||||
var requestUrl = $"{baseUrl}{tenantListPath}?pageNo=1&pageSize=200";
|
||||
var headers = new Dictionary<string, string> { ["X-Access-Token"] = token };
|
||||
var json = await _jeecgGateway.ExecuteGetStringAsync(requestUrl, headers);
|
||||
if (string.IsNullOrWhiteSpace(json)) return 0;
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
if (!(root.TryGetProperty("success", out var successEl) && successEl.GetBoolean())) return 0;
|
||||
if (!root.TryGetProperty("result", out var resultEl) || resultEl.ValueKind != JsonValueKind.Object) return 0;
|
||||
if (!resultEl.TryGetProperty("records", out var recordsEl) || recordsEl.ValueKind != JsonValueKind.Array) return 0;
|
||||
|
||||
var templateTenant = await _dbContext.Queryable<SysTenant>().ClearFilter().OrderBy(t => t.Id).FirstAsync();
|
||||
if (templateTenant == null) return 0;
|
||||
|
||||
var synced = 0;
|
||||
foreach (var tenantEl in recordsEl.EnumerateArray())
|
||||
{
|
||||
if (!tenantEl.TryGetProperty("id", out var idEl) || !idEl.TryGetInt64(out var tenantId) || tenantId <= 0) continue;
|
||||
var name = tenantEl.TryGetProperty("name", out var nameEl) ? nameEl.GetString() : null;
|
||||
var logo = 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(name) ? t.Title : name))
|
||||
.SetColumns(t => t.Logo == logo)
|
||||
.SetColumns(t => t.Status == status)
|
||||
.SetColumns(t => t.UpdateTime == DateTime.Now)
|
||||
.Where(t => t.Id == tenantId)
|
||||
.ExecuteCommandAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
var entity = new SysTenant
|
||||
{
|
||||
Id = tenantId,
|
||||
UserId = templateTenant.UserId,
|
||||
OrgId = templateTenant.OrgId,
|
||||
TenantType = templateTenant.TenantType,
|
||||
DbType = templateTenant.DbType,
|
||||
Connection = templateTenant.Connection,
|
||||
ConfigId = templateTenant.ConfigId,
|
||||
SlaveConnections = templateTenant.SlaveConnections,
|
||||
EnableReg = templateTenant.EnableReg,
|
||||
RegWayId = templateTenant.RegWayId,
|
||||
Logo = logo,
|
||||
Title = string.IsNullOrWhiteSpace(name) ? $"租户{tenantId}" : name,
|
||||
ViceTitle = templateTenant.ViceTitle,
|
||||
ViceDesc = templateTenant.ViceDesc,
|
||||
Watermark = templateTenant.Watermark,
|
||||
Copyright = templateTenant.Copyright,
|
||||
Icp = templateTenant.Icp,
|
||||
IcpUrl = templateTenant.IcpUrl,
|
||||
OrderNo = templateTenant.OrderNo,
|
||||
Remark = templateTenant.Remark,
|
||||
Status = status,
|
||||
CreateTime = DateTime.Now,
|
||||
UpdateTime = DateTime.Now
|
||||
};
|
||||
await _dbContext.Insertable(entity).ExecuteCommandAsync();
|
||||
}
|
||||
synced++;
|
||||
}
|
||||
return synced;
|
||||
}
|
||||
|
||||
/// <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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace YY.Admin.Services.Service
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取用户分页列表输入参数
|
||||
/// </summary>
|
||||
public class PageUserInput : PagedRequestBase
|
||||
{
|
||||
/// <summary>
|
||||
/// 租户Id
|
||||
/// </summary>
|
||||
public long TenantId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 账号
|
||||
/// </summary>
|
||||
public string Account { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 姓名
|
||||
/// </summary>
|
||||
public string RealName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 昵称
|
||||
/// </summary>
|
||||
public string NickName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 性别
|
||||
/// </summary>
|
||||
public GenderEnum? Sex { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 职位名称
|
||||
/// </summary>
|
||||
public string PosName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 手机号
|
||||
/// </summary>
|
||||
public string Phone { get; set; }
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 状态
|
||||
/// </summary>
|
||||
public StatusEnum? Status { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 查询时所选机构Id
|
||||
/// </summary>
|
||||
public long OrgId { get; set; }
|
||||
|
||||
public DateTime? BeginTime { get; set; }
|
||||
|
||||
public DateTime? EndTime { get; set; }
|
||||
}
|
||||
}
|
||||
154
yy-admin-master/YY.Admin.Services/Service/User/Dto/UserOutput.cs
Normal file
154
yy-admin-master/YY.Admin.Services/Service/User/Dto/UserOutput.cs
Normal file
@@ -0,0 +1,154 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace YY.Admin.Services.Service
|
||||
{
|
||||
public class UserOutput : BindableBase
|
||||
{
|
||||
/// <summary>
|
||||
/// 账号
|
||||
/// </summary>
|
||||
[BindDescription("账号", ShowScheme.普通文本, "Auto", 0 )]
|
||||
public string Account { get; set; }
|
||||
/// <summary>
|
||||
/// 姓名
|
||||
/// </summary>
|
||||
[BindDescription("姓名", ShowScheme.普通文本, "1", 1)]
|
||||
public virtual string RealName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 昵称
|
||||
/// </summary>
|
||||
[BindDescription("昵称", ShowScheme.普通文本, "Auto", 2)]
|
||||
public string? NickName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 头像
|
||||
/// </summary>
|
||||
public string? Avatar { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 性别-男_1、女_2
|
||||
/// </summary>
|
||||
public GenderEnum Sex { get; set; }
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 出生日期
|
||||
/// </summary>
|
||||
public DateTime? Birthday { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 年龄
|
||||
/// </summary>
|
||||
public int Age { get; set; }
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 手机号码
|
||||
/// </summary>
|
||||
public string? Phone { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 证件类型
|
||||
/// </summary>
|
||||
public CardTypeEnum CardType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 身份证号
|
||||
/// </summary>
|
||||
public string? IdCardNum { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 邮箱
|
||||
/// </summary>
|
||||
public string? Email { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 文化程度
|
||||
/// </summary>
|
||||
public CultureLevelEnum CultureLevel { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 政治面貌
|
||||
/// </summary>
|
||||
public string? PoliticalOutlook { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 毕业院校
|
||||
/// </summary>
|
||||
public string? College { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 办公电话
|
||||
/// </summary>
|
||||
public string? OfficePhone { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 紧急联系人
|
||||
/// </summary>
|
||||
public string? EmergencyContact { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 紧急联系人电话
|
||||
/// </summary>
|
||||
public string? EmergencyPhone { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态
|
||||
/// </summary>
|
||||
private StatusEnum _status;
|
||||
public StatusEnum Status { get => _status; set => SetProperty(ref _status, value); }
|
||||
|
||||
/// <summary>
|
||||
/// 账号类型
|
||||
/// </summary>
|
||||
public AccountTypeEnum AccountType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 创建时间
|
||||
/// </summary>
|
||||
public DateTime? CreateTime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 机构名称
|
||||
/// </summary>
|
||||
[BindDescription("机构名称", ShowScheme.普通文本, "Auto", 3)]
|
||||
public string OrgName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 职位名称
|
||||
/// </summary>s
|
||||
[BindDescription("职位名称", ShowScheme.普通文本, "Auto", 4)]
|
||||
public string PosName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 角色名称
|
||||
/// </summary>
|
||||
[BindDescription("角色名称", ShowScheme.普通文本, "Auto", 5)]
|
||||
public string RoleName { get; set; }
|
||||
|
||||
private bool _isSelected;
|
||||
public bool IsSelected
|
||||
{
|
||||
get => _isSelected;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _isSelected, value))
|
||||
{
|
||||
//SelectionChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 添加主键ID,用于批量删除
|
||||
public long Id { get; set; }
|
||||
|
||||
//public event EventHandler SelectionChanged;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace YY.Admin.Services.Service.User
|
||||
{
|
||||
public interface ISysUserRoleService
|
||||
{
|
||||
/// <summary>
|
||||
/// 根据用户Id获取角色Id集合
|
||||
/// </summary>
|
||||
/// <param name="userId"></param>
|
||||
/// <returns></returns>
|
||||
Task<List<long>> GetUserRoleIdList(long userId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using YY.Admin.Services.Service.User;
|
||||
|
||||
namespace YY.Admin.Services.Service.User
|
||||
{
|
||||
public interface ISysUserService
|
||||
{
|
||||
Task<List<SysUser>> GetUsersAsync();
|
||||
|
||||
Task<SqlSugarPagedList<UserOutput>> PageAsync(PageUserInput input);
|
||||
|
||||
Task<int> BatchDeleteAsync(List<long> ids);
|
||||
|
||||
Task<int> DeleteAsync(long id);
|
||||
|
||||
Task<int> CreateAsync(SysUser sysUser);
|
||||
|
||||
Task<int> UpdateAsync(SysUser sysUser);
|
||||
|
||||
Task<long> ReadMaxIdAsync();
|
||||
|
||||
Task<bool> AccountExistsAsync(string account, long? excludeUserId = null);
|
||||
|
||||
Task<int> ToggleStatus(SysUser sysUser);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using SqlSugar;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace YY.Admin.Services.Service.User
|
||||
{
|
||||
public class SysUserRoleService : ISysUserRoleService, ISingletonDependency
|
||||
{
|
||||
private readonly ISqlSugarClient _dbContext;
|
||||
public SysUserRoleService(ISqlSugarClient context) {
|
||||
_dbContext=context;
|
||||
}
|
||||
/// <summary>
|
||||
///根据用户Id获取角色Id集合
|
||||
/// </summary>
|
||||
/// <param name="userId"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<List<long>> GetUserRoleIdList(long userId)
|
||||
{
|
||||
return await _dbContext.Queryable<SysUserRole>()
|
||||
.Where(u => u.UserId == userId).Select(u => u.RoleId).ToListAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
239
yy-admin-master/YY.Admin.Services/Service/User/SysUserService.cs
Normal file
239
yy-admin-master/YY.Admin.Services/Service/User/SysUserService.cs
Normal file
@@ -0,0 +1,239 @@
|
||||
|
||||
using Dm.util;
|
||||
using SqlSugar;
|
||||
using System.Globalization;
|
||||
using YY.Admin.Core;
|
||||
using YY.Admin.Core.SeedData;
|
||||
using YY.Admin.Core.Session;
|
||||
using YY.Admin.Core.Util;
|
||||
|
||||
namespace YY.Admin.Services.Service.User
|
||||
{
|
||||
public class SysUserService : ISysUserService, ISingletonDependency
|
||||
{
|
||||
private readonly ISysOrgService _sysOrgService;
|
||||
private readonly ISqlSugarClient _dbContext;
|
||||
public SysUserService(ISysOrgService orgService, ISqlSugarClient dbContext)
|
||||
{
|
||||
_sysOrgService = orgService;
|
||||
_dbContext = dbContext;
|
||||
}
|
||||
|
||||
public async Task<List<SysUser>> GetUsersAsync()
|
||||
{
|
||||
await Task.Delay(200);
|
||||
return new List<SysUser>();
|
||||
}
|
||||
|
||||
public async Task<SqlSugarPagedList<UserOutput>> PageAsync(PageUserInput input)
|
||||
{
|
||||
var sexFilter = input.Sex.HasValue ? (int?)input.Sex.Value : null;
|
||||
var statusFilter = input.Status.HasValue ? (int?)input.Status.Value : null;
|
||||
|
||||
// 账号管理查询改为从 Jeecg 同构账号表读取
|
||||
var query = _dbContext.Queryable<JeecgSysUser>().ClearFilter()
|
||||
.WhereIF(input.TenantId > 0, u => u.LoginTenantId == input.TenantId)
|
||||
.WhereIF(!string.IsNullOrWhiteSpace(input.Account), u => u.Username != null && u.Username.Contains(input.Account))
|
||||
.WhereIF(!string.IsNullOrWhiteSpace(input.RealName), u => u.Realname != null && u.Realname.Contains(input.RealName))
|
||||
.WhereIF(!string.IsNullOrWhiteSpace(input.Phone), u => u.Phone != null && u.Phone.Contains(input.Phone))
|
||||
.WhereIF(input.BeginTime.HasValue, u => u.CreateTime >= input.BeginTime)
|
||||
.WhereIF(input.EndTime.HasValue, u => u.CreateTime <= input.EndTime)
|
||||
.OrderBy(u => SqlFunc.Desc(u.CreateTime));
|
||||
if (sexFilter.HasValue)
|
||||
{
|
||||
var sexValue = sexFilter.Value;
|
||||
query = query.Where(u => u.Sex == sexValue);
|
||||
}
|
||||
if (statusFilter.HasValue)
|
||||
{
|
||||
var statusValue = statusFilter.Value;
|
||||
query = query.Where(u => u.Status == statusValue);
|
||||
}
|
||||
|
||||
var pageData = await query.ToPagedListAsync(input.Page, input.PageSize);
|
||||
var mapped = pageData.Items.Select(u =>
|
||||
{
|
||||
long id = 0;
|
||||
long.TryParse(u.Id, NumberStyles.Integer, CultureInfo.InvariantCulture, out id);
|
||||
var sex = GenderEnum.Unknown;
|
||||
if (u.Sex == 1) sex = GenderEnum.Male;
|
||||
if (u.Sex == 2) sex = GenderEnum.Female;
|
||||
var status = u.Status == 1 ? StatusEnum.Enable : StatusEnum.Disable;
|
||||
return new UserOutput
|
||||
{
|
||||
Id = id,
|
||||
Account = u.Username ?? string.Empty,
|
||||
RealName = u.Realname ?? string.Empty,
|
||||
// Jeecg 同构表无 nickname 字段,昵称回退为真实姓名,避免页面显示被“清空”
|
||||
NickName = string.IsNullOrWhiteSpace(u.Realname) ? (u.Username ?? string.Empty) : u.Realname,
|
||||
Avatar = u.Avatar,
|
||||
Sex = sex,
|
||||
Birthday = u.Birthday,
|
||||
Phone = u.Phone,
|
||||
Email = u.Email,
|
||||
OfficePhone = u.Telephone,
|
||||
Status = status,
|
||||
CreateTime = u.CreateTime,
|
||||
OrgName = u.OrgCode ?? string.Empty,
|
||||
PosName = u.PositionType ?? string.Empty,
|
||||
RoleName = string.Empty,
|
||||
AccountType = AccountTypeEnum.NormalUser
|
||||
};
|
||||
}).ToList();
|
||||
|
||||
return new SqlSugarPagedList<UserOutput>
|
||||
{
|
||||
Page = pageData.Page,
|
||||
PageSize = pageData.PageSize,
|
||||
Items = mapped,
|
||||
Total = pageData.Total,
|
||||
TotalPages = pageData.TotalPages,
|
||||
HasNextPage = pageData.HasNextPage,
|
||||
HasPrevPage = pageData.HasPrevPage
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<int> BatchDeleteAsync(List<long> ids)
|
||||
{
|
||||
int count = 0;
|
||||
if (ids == null || ids.isEmpty())
|
||||
{
|
||||
return count;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await _dbContext.AsTenant().BeginTranAsync();
|
||||
|
||||
count = await _dbContext.Deleteable<SysUser>().In(ids).ExecuteCommandAsync();
|
||||
|
||||
await _dbContext.AsTenant().CommitTranAsync();
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
await _dbContext.AsTenant().RollbackTranAsync();
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
public async Task<int> DeleteAsync(long id)
|
||||
{
|
||||
int count = 0;
|
||||
try
|
||||
{
|
||||
await _dbContext.AsTenant().BeginTranAsync();
|
||||
|
||||
count = await _dbContext.Deleteable<SysUser>().In(id).ExecuteCommandAsync();
|
||||
|
||||
await _dbContext.AsTenant().CommitTranAsync();
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
await _dbContext.AsTenant().RollbackTranAsync();
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
public async Task<int> CreateAsync(SysUser sysUser)
|
||||
{
|
||||
long maxId = await ReadMaxIdAsync();
|
||||
|
||||
sysUser.Id = ++maxId;
|
||||
sysUser.Password = CryptogramUtil.Encrypt(sysUser.Password);
|
||||
sysUser.CardType = CardTypeEnum.IdCard;
|
||||
sysUser.CultureLevel = CultureLevelEnum.Level0;
|
||||
sysUser.PosId = new SysPosSeedData().HasData().ToList()[0].Id;
|
||||
sysUser.TenantId = SqlSugarConst.DefaultTenantId;
|
||||
sysUser.CreateTime = DateTime.Now;
|
||||
sysUser.CreateUserId = AppSession.UserId;
|
||||
sysUser.CreateUserName = AppSession.CurrentUser!.Account;
|
||||
|
||||
int count = 0;
|
||||
try
|
||||
{
|
||||
await _dbContext.AsTenant().BeginTranAsync();
|
||||
|
||||
count = await _dbContext.Insertable(sysUser).ExecuteCommandAsync();
|
||||
|
||||
await _dbContext.AsTenant().CommitTranAsync();
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
await _dbContext.AsTenant().RollbackTranAsync();
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
public async Task<int> UpdateAsync(SysUser sysUser)
|
||||
{
|
||||
sysUser.UpdateUserId = AppSession.UserId; ;
|
||||
sysUser.UpdateUserName = AppSession.CurrentUser!.Account;
|
||||
sysUser.UpdateTime = DateTime.Now;
|
||||
|
||||
int count = 0;
|
||||
try
|
||||
{
|
||||
await _dbContext.AsTenant().BeginTranAsync();
|
||||
|
||||
count = await _dbContext.Updateable(sysUser)
|
||||
.UpdateColumns(it => new { it.RealName, it.NickName, it.Sex, it.Birthday, it.Age, it.Status, it.UpdateUserId, it.UpdateUserName, it.UpdateTime })
|
||||
.ExecuteCommandAsync();
|
||||
|
||||
await _dbContext.AsTenant().CommitTranAsync();
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
await _dbContext.AsTenant().RollbackTranAsync();
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
public async Task<long> ReadMaxIdAsync()
|
||||
{
|
||||
return await _dbContext.Queryable<SysUser>().MaxAsync<long>("Id");
|
||||
}
|
||||
|
||||
public async Task<bool> AccountExistsAsync(string account, long? excludeUserId)
|
||||
{
|
||||
var query = _dbContext.Queryable<SysUser>()
|
||||
. Where(u => u.Account == account);
|
||||
|
||||
// excludeUserId不等于null && 不等于 0
|
||||
if (excludeUserId.HasValue && excludeUserId != 0)
|
||||
{
|
||||
query = query.Where(u => u.Id != excludeUserId.Value);
|
||||
}
|
||||
|
||||
return await query.AnyAsync();
|
||||
}
|
||||
|
||||
public async Task<int> ToggleStatus(SysUser sysUser)
|
||||
{
|
||||
sysUser.UpdateUserId = AppSession.UserId; ;
|
||||
sysUser.UpdateUserName = AppSession.CurrentUser!.Account;
|
||||
sysUser.UpdateTime = DateTime.Now;
|
||||
|
||||
int count = 0;
|
||||
try
|
||||
{
|
||||
await _dbContext.AsTenant().BeginTranAsync();
|
||||
|
||||
count = await _dbContext.Updateable(sysUser)
|
||||
.UpdateColumns(it => new { it.Status, it.UpdateUserId, it.UpdateUserName, it.UpdateTime })
|
||||
.ExecuteCommandAsync();
|
||||
|
||||
await _dbContext.AsTenant().CommitTranAsync();
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
await _dbContext.AsTenant().RollbackTranAsync();
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
}
|
||||
}
|
||||
27
yy-admin-master/YY.Admin.Services/YY.Admin.Services.csproj
Normal file
27
yy-admin-master/YY.Admin.Services/YY.Admin.Services.csproj
Normal file
@@ -0,0 +1,27 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0-windows10.0.19041</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<UseWPF>true</UseWPF>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>preview</LangVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="Service\Config\Dto\" />
|
||||
<Folder Include="Service\Role\Dto\" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\YY.Admin.Core\YY.Admin.Core.csproj">
|
||||
</ProjectReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="Configuration\appsettings.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user