更新项目配置,新增设备同步模块,优化WebSocket和Swagger配置,增强SCADA系统的免登录接口,支持数据字典项和登录日志的免登录查询与记录。调整Java编译设置,确保更好的开发体验。

This commit is contained in:
geht
2026-04-28 10:23:58 +08:00
parent bbe46dcf2d
commit 142a0bdaba
1013 changed files with 41858 additions and 28 deletions

View File

@@ -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, // trueJeecg 登录成功后拉取用户列表并写入本地(关闭则不会同步用户表)
"UseJeecgUserIdAsLocalPrimaryKey": true, // true 时本地 sys_user.id 与 Jeecg getUserInfo 的 id 一致(雪花 long
"UserSyncSkipUnchanged": true, // 与Jeecg updateTime 一致时跳过写库,减少重复保存
"UserListUseUpdateTimeQuery": false, // 仅非 SCADA 的 /sys/user/listtrue 时附带 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
}
}

View 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;

View File

@@ -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; }
}

View File

@@ -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; }
}
}

View File

@@ -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);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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));
}
}
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -0,0 +1,11 @@
namespace YY.Admin.Services.Service;
public interface IJeecgDictSyncService
{
Task<SqlSugarPagedList<JeecgDictItemOutput>> PageAsync(PageJeecgDictItemInput input);
/// <summary>
/// 从 Jeecg 后端同步数据字典到本地同构表
/// </summary>
Task<int> SyncFromJeecgAsync();
}

View File

@@ -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;
}
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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();
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,16 @@
using System.Text.Json.Serialization;
namespace YY.Admin.Services.Service.Jeecg
{
/// <summary>
/// Jeecg 同步本地状态(工控机断网续传水位)
/// </summary>
public class JeecgSyncState
{
/// <summary>
/// 同步水位UTC标准列表作 updateTime_beginSCADA 作 updatedAfter与接口文档游标一致
/// </summary>
[JsonPropertyName("lastUserListSyncUtc")]
public DateTime? LastUserListSyncUtc { get; set; }
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View 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; }
}
}

View File

@@ -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();
}
}

View 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);
}
}
}

View File

@@ -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);
}
}

View File

@@ -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();
}
}
}

View File

@@ -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);
}
}

View File

@@ -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();
}
}
}

View File

@@ -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; }
}
}

View File

@@ -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; }
}
}

View File

@@ -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();
}
}

View File

@@ -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;
}
}
}

View File

@@ -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; }
}
}

View 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;
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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();
}
}
}

View 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;
}
}
}

View 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>