Просмотр исходного кода

fix: SqlSugar CodeFirst 健壮性、NbrControl 列映射与进程级异常处理

- SqlSugarSetup: 预迁移反射异常、视图初始化按视图容错、多余列改可空按列跳过、单表失败可配置跳过、等待库超时可选
- DbConnectionOptions/Database.json: ContinueInitTableOnEntityFailure 配置说明
- AdoS0NbrControl + EntityService: 遗留表 Pascal 审计列与 UnderLine 对齐
- Web.Entry: AppDomain/Task 未处理异常日志;后台服务异常不默认停止 Host
- chore: bump version Web 2.4.113 / server 1.0.80

Made-with: Cursor
skygu 3 месяцев назад
Родитель
Сommit
e5b215c9ac

+ 1 - 1
Web/package.json

@@ -1,7 +1,7 @@
 {
 	"name": "admin.net",
 	"type": "module",
-	"version": "2.4.108",
+	"version": "2.4.113",
 	"packageManager": "pnpm@10.32.1",
 	"lastBuildTime": "2026.03.15",
 	"description": "Admin.NET 站在巨人肩膀上的 .NET 通用权限开发框架",

+ 2 - 1
server/Admin.NET.Application/Configuration/Database.json

@@ -43,7 +43,8 @@
           "InitTableMaxDegreeOfParallelism": 1, // 远端 MySQL DDL 初始化串行执行,避免元数据锁互相阻塞
           "InitTableRetryCount": 3, // 表结构初始化失败重试次数
           "InitTableRetryIntervalMs": 2000, // 表结构初始化失败重试间隔(毫秒)
-          "InitTableCommandTimeoutSeconds": 120 // 表结构初始化命令超时时间(秒)
+          "InitTableCommandTimeoutSeconds": 120, // 表结构初始化命令超时时间(秒)
+          // "ContinueInitTableOnEntityFailure": false // 单实体 CodeFirst 失败时是否跳过并继续(默认 false;救急可 true,或环境变量 AIDOP_CODEFIRST_CONTINUE_ON_ENTITY_ERROR=1)
         },
         "SeedSettings": {
           "EnableInitSeed": false, // 启用种子初始化(需要初始化字典/菜单数据时改 true)

+ 6 - 0
server/Admin.NET.Core/Option/DbConnectionOptions.cs

@@ -137,6 +137,12 @@ public sealed class TableSettings
     /// 表初始化命令超时时间(秒)
     /// </summary>
     public int InitTableCommandTimeoutSeconds { get; set; } = 120;
+
+    /// <summary>
+    /// 单个实体 CodeFirst 失败时是否跳过并继续其余表(默认 false)。生产应修库并保持 false;
+    /// 联调救急可改 true,或设置环境变量 AIDOP_CODEFIRST_CONTINUE_ON_ENTITY_ERROR=1(优先于本项)。
+    /// </summary>
+    public bool ContinueInitTableOnEntityFailure { get; set; }
 }
 
 /// <summary>

+ 116 - 24
server/Admin.NET.Core/SqlSugar/SqlSugarSetup.cs

@@ -4,6 +4,7 @@
 //
 // 不得利用本项目从事危害国家安全、扰乱社会秩序、侵犯他人合法权益等法律法规禁止的活动!任何基于本项目二次开发而产生的一切法律纠纷和责任,我们不承担任何责任!
 
+using System.Reflection;
 using Microsoft.Data.Sqlite;
 using DbType = SqlSugar.DbType;
 
@@ -109,7 +110,16 @@ public static class SqlSugarSetup
                 if (new NullabilityInfoContext().Create(type).WriteState is NullabilityState.Nullable)
                     column.IsNullable = true;
                 if (config.DbSettings.EnableUnderLine && !column.IsIgnore && !column.DbColumnName.Contains('_'))
-                    column.DbColumnName = column.DbColumnName.ToUnderLine(); // 驼峰转下划线
+                {
+                    // 遗留表 NbrControl:库中为 PascalCase 审计列。若再转下划线会变成 create_time 等,
+                    // CodeFirst 会误判缺列并执行 ADD;在要求「不改表」的部署下应保持 Pascal 物理列名。
+                    const string nbrControlTypeFullName = "Admin.NET.Plugin.AiDOP.Entity.S0.Warehouse.AdoS0NbrControl";
+                    var skipUnderline = type is PropertyInfo pi
+                        && string.Equals(pi.DeclaringType?.FullName, nbrControlTypeFullName, StringComparison.Ordinal)
+                        && (pi.Name == "CreateTime" || pi.Name == "UpdateTime" || pi.Name == "CreateUser" || pi.Name == "UpdateUser");
+                    if (!skipUnderline)
+                        column.DbColumnName = column.DbColumnName.ToUnderLine();
+                }
             },
             DataInfoCacheService = new SqlSugarCache(),
         };
@@ -367,27 +377,43 @@ public static class SqlSugarSetup
         int taskIndex = 0, size = viewTypeList.Count;
         var taskList = viewTypeList.Select(viewType => Task.Run(() =>
         {
-            // 开始计时
-            var stopWatch = Stopwatch.StartNew();
+            try
+            {
+                // 开始计时
+                var stopWatch = Stopwatch.StartNew();
 
-            // 获取视图实体和配置信息
-            var entityInfo = dbProvider.EntityMaintenance.GetEntityInfo(viewType) ?? throw new Exception("获取视图实体配置有误");
+                // 获取视图实体和配置信息
+                var entityInfo = dbProvider.EntityMaintenance.GetEntityInfo(viewType) ?? throw new Exception("获取视图实体配置有误");
 
-            // 如果视图存在,则删除视图
-            if (dbProvider.DbMaintenance.GetViewInfoList(false).Any(it => it.Name.EqualIgnoreCase(entityInfo.DbTableName)))
-                dbProvider.DbMaintenance.DropView(entityInfo.DbTableName);
+                // 如果视图存在,则删除视图
+                if (dbProvider.DbMaintenance.GetViewInfoList(false).Any(it => it.Name.EqualIgnoreCase(entityInfo.DbTableName)))
+                {
+                    try
+                    {
+                        dbProvider.DbMaintenance.DropView(entityInfo.DbTableName);
+                    }
+                    catch (Exception dropEx)
+                    {
+                        Log.Warning($"删除视图失败(可能无权限),跳过:{entityInfo.DbTableName} — {dropEx.Message}");
+                    }
+                }
 
-            // 获取初始化视图查询SQL
-            var sql = viewType.GetMethod(nameof(ISqlSugarView.GetQueryableSqlString))?.Invoke(Activator.CreateInstance(viewType), [dbProvider]) as string;
-            if (string.IsNullOrWhiteSpace(sql)) throw new Exception("视图初始化Sql语句不能为空");
+                // 获取初始化视图查询SQL
+                var sql = viewType.GetMethod(nameof(ISqlSugarView.GetQueryableSqlString))?.Invoke(Activator.CreateInstance(viewType), [dbProvider]) as string;
+                if (string.IsNullOrWhiteSpace(sql)) throw new Exception("视图初始化Sql语句不能为空");
 
-            // 创建视图
-            dbProvider.Ado.ExecuteCommand($"CREATE VIEW {entityInfo.DbTableName} AS " + Environment.NewLine + " " + sql);
+                // 创建视图
+                dbProvider.Ado.ExecuteCommand($"CREATE VIEW {entityInfo.DbTableName} AS " + Environment.NewLine + " " + sql);
 
-            // 停止计时
-            stopWatch.Stop();
-            Console.ForegroundColor = ConsoleColor.Green;
-            Console.WriteLine($"初始化视图 {viewType.FullName,-58} ({dbProvider.CurrentConnectionConfig.ConfigId} - {Interlocked.Increment(ref taskIndex):D003}/{size:D003},耗时:{stopWatch.ElapsedMilliseconds:N0} ms)");
+                // 停止计时
+                stopWatch.Stop();
+                Console.ForegroundColor = ConsoleColor.Green;
+                Console.WriteLine($"初始化视图 {viewType.FullName,-58} ({dbProvider.CurrentConnectionConfig.ConfigId} - {Interlocked.Increment(ref taskIndex):D003}/{size:D003},耗时:{stopWatch.ElapsedMilliseconds:N0} ms)");
+            }
+            catch (Exception ex)
+            {
+                Log.Warning($"初始化视图失败已跳过:{viewType.FullName} — {ex.Message}");
+            }
         }));
         Task.WaitAll(taskList.ToArray());
 
@@ -402,8 +428,18 @@ public static class SqlSugarSetup
     /// <param name="dbProvider"></param>
     private static void WaitForDatabaseReady(SqlSugarScopeProvider dbProvider)
     {
+        var maxWaitSeconds = 0;
+        _ = int.TryParse(Environment.GetEnvironmentVariable("AIDOP_DB_WAIT_MAX_SECONDS"), out maxWaitSeconds);
+        var deadline = maxWaitSeconds > 0 ? DateTime.UtcNow.AddSeconds(maxWaitSeconds) : DateTime.MaxValue;
+
         do
         {
+            if (maxWaitSeconds > 0 && DateTime.UtcNow > deadline)
+            {
+                throw new TimeoutException(
+                    $"等待数据库就绪已超过 {maxWaitSeconds} 秒(环境变量 AIDOP_DB_WAIT_MAX_SECONDS)。请检查连接串、网络与账号权限;不需要超时限制时请移除此环境变量。");
+            }
+
             try
             {
                 if (dbProvider.Ado.Connection.State != ConnectionState.Open)
@@ -494,7 +530,15 @@ public static class SqlSugarSetup
         if (migrateMethod == null)
             return;
 
-        migrateMethod.Invoke(null, [dbProvider]);
+        try
+        {
+            migrateMethod.Invoke(null, [dbProvider]);
+        }
+        catch (TargetInvocationException tie) when (tie.InnerException != null)
+        {
+            Log.Error($"AidopS0ExcelColumnMigration.Migrate 执行失败:{tie.InnerException.Message}", tie.InnerException);
+            throw tie.InnerException;
+        }
     }
 
     /// <summary>
@@ -553,11 +597,41 @@ public static class SqlSugarSetup
         var maxDegree = ResolveInitTableMaxDegreeOfParallelism(config);
         int count = 0, sum = entityTypes.Count;
 
+        var continueOnEntityFailure = ResolveContinueInitTableOnEntityFailure(config);
+
         void InitOne(Type entityType)
         {
+            var label = entityType.FullName ?? entityType.Name;
+            string tableName = "";
+            try
+            {
+                tableName = dbProvider.EntityMaintenance.GetEntityInfo(entityType).DbTableName;
+            }
+            catch
+            {
+                // 仅用于日志;后续 UpdateNullableColumns 仍会抛出更具体错误
+            }
+
             Console.WriteLine($"初始化表结构 {entityType.FullName,-64} ({config.ConfigId} - {Interlocked.Increment(ref count):D003}/{sum:D003})");
-            UpdateNullableColumns(dbProvider, entityType);
-            InitializeTable(dbProvider, entityType, config);
+            try
+            {
+                UpdateNullableColumns(dbProvider, entityType);
+                InitializeTable(dbProvider, entityType, config);
+            }
+            catch (Exception ex)
+            {
+                Log.Error($"初始化表结构失败 Entity={label} Table={tableName} ConfigId={config.ConfigId}: {ex.Message}", ex);
+                if (continueOnEntityFailure)
+                {
+                    Log.Warning(
+                        "已启用跳过单表失败(TableSettings:ContinueInitTableOnEntityFailure 或环境变量 AIDOP_CODEFIRST_CONTINUE_ON_ENTITY_ERROR=1),将继续初始化其余实体;库结构可能不完整。");
+                    return;
+                }
+
+                throw new InvalidOperationException(
+                    $"表结构初始化失败: {label}(表 {tableName})。请检查上方日志中的 DDL/SQL 异常;可临时关闭 TableSettings:EnableInitTable,或仅在联调时开启 ContinueInitTableOnEntityFailure / AIDOP_CODEFIRST_CONTINUE_ON_ENTITY_ERROR=1。",
+                    ex);
+            }
         }
 
         if (maxDegree <= 1)
@@ -582,11 +656,19 @@ public static class SqlSugarSetup
 
         foreach (var dbColumn in dbColumns.Where(c => !c.IsPrimarykey && entityInfo.Columns.All(u => u.DbColumnName != c.DbColumnName)))
         {
-            dbColumn.IsNullable = true;
-            Retry(() =>
+            try
             {
-                dbProvider.DbMaintenance.UpdateColumn(entityInfo.DbTableName, dbColumn);
-            }, maxRetry: 3, retryIntervalMs: 1000);
+                dbColumn.IsNullable = true;
+                Retry(() =>
+                {
+                    dbProvider.DbMaintenance.UpdateColumn(entityInfo.DbTableName, dbColumn);
+                }, maxRetry: 3, retryIntervalMs: 1000);
+            }
+            catch (Exception ex)
+            {
+                // 低权限或列类型不支持等:不应阻塞整张表 CodeFirst
+                Log.Warning($"将多余列改为可空失败,已跳过:{entityInfo.DbTableName}.{dbColumn.DbColumnName} — {ex.Message}");
+            }
         }
     }
 
@@ -828,6 +910,16 @@ public static class SqlSugarSetup
         return value <= 0 ? 120 : value;
     }
 
+    /// <summary>
+    /// 单实体 CodeFirst 失败时是否跳过并继续(环境变量优先)。
+    /// </summary>
+    private static bool ResolveContinueInitTableOnEntityFailure(DbConnectionConfig config)
+    {
+        if (string.Equals(Environment.GetEnvironmentVariable("AIDOP_CODEFIRST_CONTINUE_ON_ENTITY_ERROR"), "1", StringComparison.OrdinalIgnoreCase))
+            return true;
+        return config.TableSettings?.ContinueInitTableOnEntityFailure == true;
+    }
+
     private static bool IsRetryableDatabaseInitException(Exception ex)
     {
         if (ex is SqliteException sqliteException && sqliteException.SqliteErrorCode == 5)

+ 3 - 3
server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj

@@ -11,9 +11,9 @@
     <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
     <Copyright>Admin.NET</Copyright>
     <Description>Admin.NET 通用权限开发平台</Description>
-    <AssemblyVersion>1.0.75</AssemblyVersion>
-    <FileVersion>1.0.75</FileVersion>
-    <Version>1.0.75</Version>
+    <AssemblyVersion>1.0.80</AssemblyVersion>
+    <FileVersion>1.0.80</FileVersion>
+    <Version>1.0.80</Version>
   </PropertyGroup>
 
   <ItemGroup>

+ 65 - 0
server/Admin.NET.Web.Entry/GlobalProcessExceptionHandlers.cs

@@ -0,0 +1,65 @@
+// Admin.NET 项目的版权、商标、专利和其他相关权利均受相应法律法规的保护。使用本项目应遵守相关法律法规和许可证的要求。
+//
+// 本项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。许可证位于源代码树根目录中的 LICENSE-MIT 和 LICENSE-APACHE 文件。
+//
+// 不得利用本项目从事危害国家安全、扰乱社会秩序、侵犯他人合法权益等法律法规禁止的活动!任何基于本项目二次开发而产生的一切法律纠纷和责任,我们不承担任何责任!
+
+namespace Admin.NET.Web.Entry;
+
+/// <summary>
+/// 进程级未处理异常:在 DI/日志管道就绪前尽量落盘到控制台,便于容器与本地排障。
+/// 说明:.NET 中 <see cref="AppDomain.UnhandledException"/> 在 <c>IsTerminating</c> 时无法阻止进程退出,仅能最后记录。
+/// </summary>
+public static class GlobalProcessExceptionHandlers
+{
+    private static int _registered;
+
+    public static void Register()
+    {
+        if (Interlocked.Exchange(ref _registered, 1) != 0)
+            return;
+
+        AppDomain.CurrentDomain.UnhandledException += OnUnhandledException;
+        TaskScheduler.UnobservedTaskException += OnUnobservedTaskException;
+    }
+
+    private static void OnUnhandledException(object sender, UnhandledExceptionEventArgs e)
+    {
+        try
+        {
+            var terminating = e.IsTerminating ? "即将终止进程" : "非终止";
+            var ex = e.ExceptionObject as Exception;
+            var headline = $"[{DateTime.UtcNow:O}] AppDomain 未处理异常 ({terminating})";
+            Console.Error.WriteLine(headline);
+            if (ex != null)
+                Console.Error.WriteLine(ex);
+            else
+                Console.Error.WriteLine(e.ExceptionObject);
+        }
+        catch
+        {
+            // 避免回调内再抛导致二次崩溃
+        }
+    }
+
+    private static void OnUnobservedTaskException(object sender, UnobservedTaskExceptionEventArgs e)
+    {
+        try
+        {
+            Console.Error.WriteLine($"[{DateTime.UtcNow:O}] TaskScheduler 未观察到的任务异常:");
+            Console.Error.WriteLine(e.Exception);
+            e.SetObserved();
+        }
+        catch
+        {
+            try
+            {
+                e.SetObserved();
+            }
+            catch
+            {
+                // ignore
+            }
+        }
+    }
+}

+ 10 - 1
server/Admin.NET.Web.Entry/Program.cs

@@ -4,12 +4,21 @@
 //
 // 不得利用本项目从事危害国家安全、扰乱社会秩序、侵犯他人合法权益等法律法规禁止的活动!任何基于本项目二次开发而产生的一切法律纠纷和责任,我们不承担任何责任!
 
+// 进程最外层:未观察 Task、AppDomain 未处理异常等(在 Serve 构建主机前注册)
+Admin.NET.Web.Entry.GlobalProcessExceptionHandlers.Register();
+
 Serve.Run(RunOptions.Default.AddWebComponent<WebComponent>());
 
 public class WebComponent : IWebComponent
 {
     public void Load(WebApplicationBuilder builder, ComponentContext componentContext)
     {
+        // 后台服务未处理异常时默认会停止整个 Host;改为记录并继续,避免单点任务拖垮整进程(仍应修复业务异常)
+        builder.Host.ConfigureHostOptions(options =>
+        {
+            options.BackgroundServiceExceptionBehavior = Microsoft.Extensions.Hosting.BackgroundServiceExceptionBehavior.Ignore;
+        });
+
         // 设置日志过滤
         builder.Logging.AddFilter((provider, category, logLevel) =>
         {
@@ -24,4 +33,4 @@ public class WebComponent : IWebComponent
             u.Limits.MaxRequestBodySize = null;
         });
     }
-}
+}

+ 1 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S0/Warehouse/AdoS0NbrControl.cs

@@ -103,6 +103,7 @@ public class AdoS0NbrControl : ITenantIdFilter
     [SugarColumn(ColumnName = "IsChanged", ColumnDescription = "是否已变更", ColumnDataType = "boolean", IsNullable = true)]
     public bool? IsChanged { get; set; }
 
+    // 与现网表一致:PascalCase 审计列(CreateTime 等)。EnableUnderLine 时由 SqlSugarSetup 对本类型跳过列名下划线转换,避免误加 create_time。
     [SugarColumn(ColumnName = "CreateUser", ColumnDescription = "创建人", Length = 24, IsNullable = true)]
     public string? CreateUser { get; set; }