Bladeren bron

fix(mdp): 数据源口令改由环境变量解析,不再从仓库配置读取 T8 连接

server 1.0.595

Co-authored-by: Cursor <cursoragent@cursor.com>
YY968XX 11 uur geleden
bovenliggende
commit
294e32e1df

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

@@ -52,32 +52,9 @@
           "EnableInitSeed": false, // 启用种子初始化(需要初始化字典/菜单数据时改 true)
           "EnableIncreSeed": false // 启用种子增量更新(只更新贴了特性[IncreSeed]的种子表)
         }
-      },
-      // T8 源库(SQL Server 只读)— S5/S6/S7 KPI 数据贴源同步用
-      // 接入边界:只读 SELECT;只在 refresh 作业里调用;禁止看板实时查 T8;禁止高频调度
-      // 所有 EnableInit*/EnableIncre* 强制 false:外部源库,DOP 不得对 T8 做任何 DDL / 种子写入
-      // 凭据来源:CLAUDE.md 「T8 源库连接信息(S5 / S6 / S7 KPI 对接用)」节
-      {
-        "ConfigId": "t8_v5",
-        "DbType": "SqlServer",
-        "DbNickName": "T8 源库(只读)",
-        "ConnectionString": "Server=39.105.125.212,29289;Database=t8_V5_09_56;User Id=dopsa;Password=&erp@2013!14.;TrustServerCertificate=true;Encrypt=false;Pooling=true;Min Pool Size=0;Max Pool Size=10;Connect Timeout=15;",
-        "DbSettings": {
-          "EnableInitDb": false, // T8 是外部源库,禁止 DOP 端建库
-          "EnableInitView": false, // 禁止 DOP 端建视图
-          "EnableDiffLog": false,
-          "EnableUnderLine": false,
-          "EnableConnEncrypt": false
-        },
-        "TableSettings": {
-          "EnableInitTable": false, // 禁止 DOP 端对 T8 做表结构 CodeFirst
-          "EnableIncreTable": false
-        },
-        "SeedSettings": {
-          "EnableInitSeed": false,
-          "EnableIncreSeed": false
-        }
       }
+      
+      
       //// 日志独立数据库配置
       //{
       //  "ConfigId": "1300000000002", // 日志库标识-禁止修改

+ 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.594</AssemblyVersion>
-    <FileVersion>1.0.594</FileVersion>
-    <Version>1.0.594</Version>
+    <AssemblyVersion>1.0.595</AssemblyVersion>
+    <FileVersion>1.0.595</FileVersion>
+    <Version>1.0.595</Version>
   </PropertyGroup>
 
   <ItemGroup>

+ 148 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/DataPlatform/MdpSourcePasswordResolverTests.cs

@@ -0,0 +1,148 @@
+using Admin.NET.Plugin.AiDOP.DataPlatform;
+using Admin.NET.Plugin.AiDOP.Job;
+using Microsoft.Extensions.Logging;
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.DataPlatform;
+
+public class MdpSourcePasswordResolverTests
+{
+    private const string SourceCode = "UNIT_TEST_SRC";
+    private const string Secret = "unit-test-secret-value";
+
+    [Fact]
+    public void Env_wins_over_stored_secret()
+    {
+        var env = MdpSourcePasswordResolver.EnvironmentVariableName(SourceCode);
+        var previous = Environment.GetEnvironmentVariable(env);
+        try
+        {
+            Environment.SetEnvironmentVariable(env, Secret);
+            var logger = new ListLogger();
+            var value = new MdpSourcePasswordResolver(logger).Resolve(SourceCode, "stored-cipher");
+            Assert.Equal(Secret, value);
+            Assert.DoesNotContain(Secret, logger.Text);
+        }
+        finally
+        {
+            Environment.SetEnvironmentVariable(env, previous);
+        }
+    }
+
+    [Fact]
+    public void Missing_env_uses_successful_decrypt_without_logging_it()
+    {
+        var env = MdpSourcePasswordResolver.EnvironmentVariableName(SourceCode);
+        var previous = Environment.GetEnvironmentVariable(env);
+        try
+        {
+            Environment.SetEnvironmentVariable(env, null);
+            var logger = new ListLogger();
+            var value = MdpSourcePasswordResolver.ResolveCore(
+                SourceCode, "cipher-blob", _ => "opened-secret", logger);
+            Assert.Equal("opened-secret", value);
+            Assert.DoesNotContain("cipher-blob", logger.Text);
+            Assert.DoesNotContain("opened-secret", logger.Text);
+            Assert.DoesNotContain(Secret, logger.Text);
+        }
+        finally
+        {
+            Environment.SetEnvironmentVariable(env, previous);
+        }
+    }
+
+    [Fact]
+    public void Missing_env_uses_legacy_plaintext_without_logging_it()
+    {
+        var env = MdpSourcePasswordResolver.EnvironmentVariableName(SourceCode);
+        var previous = Environment.GetEnvironmentVariable(env);
+        try
+        {
+            Environment.SetEnvironmentVariable(env, null);
+            var logger = new ListLogger();
+            var value = new MdpSourcePasswordResolver(logger).Resolve(SourceCode, Secret);
+            Assert.Equal(Secret, value);
+            Assert.Contains("legacy plaintext", logger.Text);
+            Assert.DoesNotContain(Secret, logger.Text);
+        }
+        finally
+        {
+            Environment.SetEnvironmentVariable(env, previous);
+        }
+    }
+
+    [Fact]
+    public void Missing_env_and_stored_secret_throws_SECRET_MISSING()
+    {
+        var env = MdpSourcePasswordResolver.EnvironmentVariableName(SourceCode);
+        var previous = Environment.GetEnvironmentVariable(env);
+        try
+        {
+            Environment.SetEnvironmentVariable(env, null);
+            var ex = Assert.Throws<MdpSourceSecretException>(() =>
+                new MdpSourcePasswordResolver().Resolve(SourceCode, null));
+            Assert.Equal("SECRET_MISSING", ex.Code);
+            Assert.Contains(env, ex.Message);
+            Assert.DoesNotContain(Secret, ex.Message);
+        }
+        finally
+        {
+            Environment.SetEnvironmentVariable(env, previous);
+        }
+    }
+
+    [Fact]
+    public void Health_classifier_does_not_keep_connection_text()
+    {
+        Assert.Equal("SECRET_MISSING", MdpSourceHealthCheckJob.ClassifyHealthFailure(
+            new MdpSourceSecretException("SECRET_MISSING", "set the env")));
+        Assert.Equal("AUTH_FAILED", MdpSourceHealthCheckJob.ClassifyHealthFailure(
+            new InvalidOperationException("Login failed for user 'dopsa'")));
+        Assert.Equal("NETWORK_FAILED", MdpSourceHealthCheckJob.ClassifyHealthFailure(
+            new TimeoutException("Unable to connect to the server")));
+        Assert.Equal("连接失败,详情见运行日志", MdpSourceHealthCheckJob.SafeHealthMessage(
+            "Password=unit-test-secret-value;Server=example"));
+        Assert.DoesNotContain(Secret, MdpSourceHealthCheckJob.SafeHealthMessage(
+            "Password=unit-test-secret-value;Server=example"));
+    }
+
+    [Fact]
+    public void Database_json_no_longer_contains_t8_v5()
+    {
+        var dir = new DirectoryInfo(AppContext.BaseDirectory);
+        string? path = null;
+        while (dir != null)
+        {
+            var candidate = Path.Combine(dir.FullName, "server", "Admin.NET.Application", "Configuration", "Database.json");
+            if (File.Exists(candidate))
+            {
+                path = candidate;
+                break;
+            }
+            dir = dir.Parent;
+        }
+        Assert.False(string.IsNullOrEmpty(path), "Database.json not found from " + AppContext.BaseDirectory);
+        var text = File.ReadAllText(path);
+        Assert.DoesNotContain("t8_v5", text, StringComparison.OrdinalIgnoreCase);
+    }
+
+    private sealed class ListLogger : ILogger<MdpSourcePasswordResolver>
+    {
+        public string Text { get; private set; } = "";
+
+        public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
+
+        public bool IsEnabled(LogLevel logLevel) => true;
+
+        public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
+        {
+            Text += formatter(state, exception);
+        }
+
+        private sealed class NullScope : IDisposable
+        {
+            public static readonly NullScope Instance = new();
+            public void Dispose() { }
+        }
+    }
+}

+ 1 - 1
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S5/MaterialWarehouse/InventoryReconScopeTests.cs

@@ -278,7 +278,7 @@ public class InventoryReconScopeTests
         var scope = await ScopeAsync(db, tenantId, Domain);
         Assert.False(scope.IsEmpty);
 
-        var remote = await new MdpSourceScopeFactory(db).GetScopeAsync(SourceCode);
+        var remote = await new MdpSourceScopeFactory(db, new MdpSourcePasswordResolver()).GetScopeAsync(SourceCode);
 
         var (clause, locPars) = scope.BuildInClause("Location", "loc");
         var pars = new List<SugarParameter> { new("@Domain", Domain) };

+ 1 - 1
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S5/MaterialWarehouse/InventoryTenantIsolationIntegrationTests.cs

@@ -46,7 +46,7 @@ public class InventoryTenantIsolationIntegrationTests
     private static SourceDomainTenantResolver Resolver(ISqlSugarClient db) => new(db);
 
     private static Live165InventorySource Live(ISqlSugarClient db) =>
-        new(new MdpSourceScopeFactory(db), db, Options.Create(new AidopInventoryOptions()));
+        new(new MdpSourceScopeFactory(db, new MdpSourcePasswordResolver()), db, Options.Create(new AidopInventoryOptions()));
 
     private static InventoryBalanceReader Reader(ISqlSugarClient db) =>
         new(Live(db), new StdInventorySource(db, Options.Create(new AidopInventoryOptions())),

+ 1 - 1
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S5/MaterialWarehouse/StdInventoryTenantIsolationTests.cs

@@ -257,7 +257,7 @@ public class StdInventoryTenantIsolationTests
         // 故障注入:给 LIVE 一个 mdp_source 中不存在的源码 → GetScopeAsync 抛错 → 触发 fallback。
         // 生产代码零改动;STD 侧仍用正确源码。
         var brokenLive = new Live165InventorySource(
-            new MdpSourceScopeFactory(db), db, Options.Create(Opt("NO_SUCH_SOURCE_FOR_FAULT_INJECTION")));
+            new MdpSourceScopeFactory(db, new MdpSourcePasswordResolver()), db, Options.Create(Opt("NO_SUCH_SOURCE_FOR_FAULT_INJECTION")));
         var reader = new InventoryBalanceReader(
             brokenLive, Std(db), Resolver(db), Options.Create(Opt()), NullLoggerFactory.Instance);
 

+ 1 - 1
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S5/MaterialWarehouse/StockQueryUiAndPaginationTests.cs

@@ -45,7 +45,7 @@ public class StockQueryUiAndPaginationTests
     private static SourceDomainTenantResolver Resolver(ISqlSugarClient db) => new(db);
     private static StdInventorySource Std(ISqlSugarClient db) => new(db, Options.Create(Opt()));
     private static Live165InventorySource Live(ISqlSugarClient db, string sourceCode = SourceCode) =>
-        new(new MdpSourceScopeFactory(db), db, Options.Create(Opt(sourceCode)));
+        new(new MdpSourceScopeFactory(db, new MdpSourcePasswordResolver()), db, Options.Create(Opt(sourceCode)));
 
     private static InventoryBalanceQuery Q(long tenantId, string domain, int page, int pageSize,
         string location = null, string materialCode = null) => new()

+ 0 - 87
server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/MdpSourceConnectionImportService.cs

@@ -1,87 +0,0 @@
-using System.Data.Common;
-using Microsoft.Extensions.Hosting;
-using Microsoft.Extensions.Logging;
-using SqlSugar;
-
-namespace Admin.NET.Plugin.AiDOP.DataPlatform;
-
-/// <summary>
-/// 把本环境已注册的 t8_v5 连接抄进 mdp_source。只填 db_host 为空的行,不把口令写入迁移脚本或日志。
-/// </summary>
-public sealed class MdpSourceConnectionImportService : IHostedService
-{
-    private readonly ISqlSugarClient _db;
-    private readonly ILogger<MdpSourceConnectionImportService> _logger;
-
-    public MdpSourceConnectionImportService(ISqlSugarClient db, ILogger<MdpSourceConnectionImportService> logger)
-    {
-        _db = db;
-        _logger = logger;
-    }
-
-    public async Task StartAsync(CancellationToken cancellationToken)
-    {
-        try
-        {
-            if (!_db.AsTenant().IsAnyConnection("t8_v5"))
-                return;
-
-            var conn = _db.AsTenant().GetConnectionScope("t8_v5").CurrentConnectionConfig.ConnectionString;
-            if (string.IsNullOrWhiteSpace(conn))
-                return;
-
-            var builder = new DbConnectionStringBuilder { ConnectionString = conn };
-            string? Host() => First(builder, "Server", "Data Source", "Host");
-            var hostRaw = Host() ?? "";
-            var host = hostRaw;
-            int? port = null;
-            var comma = hostRaw.LastIndexOf(',');
-            if (comma > 0 && int.TryParse(hostRaw[(comma + 1)..], out var p))
-            {
-                host = hostRaw[..comma];
-                port = p;
-            }
-
-            var dbName = First(builder, "Database", "Initial Catalog");
-            var user = First(builder, "User Id", "Uid", "User ID");
-            var password = First(builder, "Password", "Pwd") ?? "";
-            if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(dbName) || string.IsNullOrWhiteSpace(user))
-                return;
-
-            var n = await _db.Ado.ExecuteCommandAsync(
-                """
-                UPDATE mdp_source
-                   SET db_type='SQLSERVER', db_host=@host, db_port=@port, db_name=@name,
-                       db_user=@user, db_password_enc=@pwd, conn_mode='EXTERNAL'
-                 WHERE source_code='T8_V5_SQLSERVER' AND IFNULL(db_host,'')=''
-                """,
-                new List<SugarParameter>
-                {
-                    new("@host", host),
-                    new("@port", port),
-                    new("@name", dbName),
-                    new("@user", user),
-                    new("@pwd", password)
-                });
-
-            if (n > 0)
-                _logger.LogInformation("已从已注册连接导入来源 T8_V5_SQLSERVER 的连接(口令已写入)");
-        }
-        catch (Exception ex)
-        {
-            _logger.LogWarning("来源连接导入跳过:{Message}", ex.Message);
-        }
-    }
-
-    public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
-
-    private static string? First(DbConnectionStringBuilder builder, params string[] keys)
-    {
-        foreach (var key in keys)
-        {
-            if (builder.TryGetValue(key, out var value) && value is string s && !string.IsNullOrWhiteSpace(s))
-                return s;
-        }
-        return null;
-    }
-}

+ 95 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/MdpSourcePasswordResolver.cs

@@ -0,0 +1,95 @@
+using System.Text.RegularExpressions;
+using Admin.NET.Core;
+using Microsoft.Extensions.Logging;
+
+namespace Admin.NET.Plugin.AiDOP.DataPlatform;
+
+public interface IMdpSourcePasswordResolver
+{
+    /// <summary>解析数据源口令。返回值不得写入日志、实体或数据库。</summary>
+    string Resolve(string sourceCode, string? storedSecret);
+}
+
+public sealed class MdpSourceSecretException : Exception
+{
+    public string Code { get; }
+
+    public MdpSourceSecretException(string code, string message) : base(message)
+    {
+        Code = code;
+    }
+}
+
+/// <summary>
+/// 口令只在建连时解析:环境变量优先,其次系统密文,最后才是迁移期明文。
+/// </summary>
+public sealed class MdpSourcePasswordResolver : IMdpSourcePasswordResolver, ITransient
+{
+    private readonly ILogger<MdpSourcePasswordResolver>? _logger;
+
+    public MdpSourcePasswordResolver(ILogger<MdpSourcePasswordResolver>? logger = null)
+    {
+        _logger = logger;
+    }
+
+    public static string EnvironmentVariableName(string sourceCode)
+    {
+        if (string.IsNullOrWhiteSpace(sourceCode))
+            throw new ArgumentException("sourceCode 不能为空", nameof(sourceCode));
+        var token = Regex.Replace(sourceCode.Trim().ToUpperInvariant(), "[^A-Z0-9]", "_");
+        return "AIDOP_MDP_SOURCE_" + token + "_PASSWORD";
+    }
+
+    public string Resolve(string sourceCode, string? storedSecret)
+        => ResolveCore(sourceCode, storedSecret, DecryptStored, _logger);
+
+    internal static string ResolveCore(
+        string sourceCode,
+        string? storedSecret,
+        Func<string, string?> decrypt,
+        ILogger? logger)
+    {
+        var envName = EnvironmentVariableName(sourceCode);
+        var fromEnv = Environment.GetEnvironmentVariable(envName);
+        if (!string.IsNullOrEmpty(fromEnv))
+            return fromEnv;
+
+        if (!string.IsNullOrEmpty(storedSecret))
+        {
+            if (TryDecrypt(storedSecret, decrypt, out var plain))
+                return plain;
+            logger?.LogWarning(
+                "mdp source {SourceCode} still uses a legacy plaintext secret. Set {EnvName} and clear the stored value.",
+                sourceCode, envName);
+            return storedSecret;
+        }
+
+        throw new MdpSourceSecretException(
+            "SECRET_MISSING",
+            $"源 {sourceCode} 未配置口令。请设置环境变量 {envName}。");
+    }
+
+    private static string? DecryptStored(string stored)
+    {
+        var decrypted = CryptogramUtil.Decrypt(stored);
+        return decrypted;
+    }
+
+    private static bool TryDecrypt(string stored, Func<string, string?> decrypt, out string plain)
+    {
+        plain = "";
+        try
+        {
+            var decrypted = decrypt(stored);
+            // 原样返回入参不是解密成功,按迁移期明文处理。
+            if (string.IsNullOrEmpty(decrypted) || string.Equals(decrypted, stored, StringComparison.Ordinal))
+                return false;
+            plain = decrypted;
+            return true;
+        }
+        catch
+        {
+            return false;
+        }
+    }
+}

+ 7 - 17
server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/MdpSourceScopeFactory.cs

@@ -1,5 +1,4 @@
 using System.Data.Common;
-using Admin.NET.Core;
 using Admin.NET.Plugin.AiDOP.Entity.DataPlatform;
 using Microsoft.Extensions.Logging;
 using SqlSugar;
@@ -14,11 +13,16 @@ public sealed class MdpSourceScopeFactory : ITransient
 {
     private const string ConfigIdPrefix = "mdp-src-";
     private readonly ISqlSugarClient _db;
+    private readonly IMdpSourcePasswordResolver _passwords;
     private readonly ILogger<MdpSourceScopeFactory>? _logger;
 
-    public MdpSourceScopeFactory(ISqlSugarClient db, ILogger<MdpSourceScopeFactory>? logger = null)
+    public MdpSourceScopeFactory(
+        ISqlSugarClient db,
+        IMdpSourcePasswordResolver passwords,
+        ILogger<MdpSourceScopeFactory>? logger = null)
     {
         _db = db;
+        _passwords = passwords ?? throw new ArgumentNullException(nameof(passwords));
         _logger = logger;
     }
 
@@ -123,7 +127,7 @@ public sealed class MdpSourceScopeFactory : ITransient
         if (string.IsNullOrWhiteSpace(source.DbHost) || string.IsNullOrWhiteSpace(source.DbName))
             throw new InvalidOperationException($"源 {source.SourceCode} 缺少 db_host/db_name");
 
-        var password = DecryptPassword(source.DbPasswordEnc);
+        var password = _passwords.Resolve(source.SourceCode, source.DbPasswordEnc);
         var port = source.DbPort;
         var user = source.DbUser ?? "";
         var dbType = MapDbType(source.DbType);
@@ -216,18 +220,4 @@ public sealed class MdpSourceScopeFactory : ITransient
         _ => new(StringComparer.OrdinalIgnoreCase)
     };
 
-    private static string DecryptPassword(string? enc)
-    {
-        if (string.IsNullOrEmpty(enc)) return "";
-        try
-        {
-            var plain = CryptogramUtil.Decrypt(enc);
-            return string.IsNullOrEmpty(plain) ? enc : plain;
-        }
-        catch
-        {
-            // 明文或非本系统密文时原样使用(与 Database.json EnableConnEncrypt=false 一致)
-            return enc;
-        }
-    }
 }

+ 1 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/T8BaseInboundMdpSyncService.cs

@@ -7,7 +7,7 @@ namespace Admin.NET.Plugin.AiDOP.DataPlatform;
 /// <summary>
 /// T8 基表双模式入站(S5/S6/S7 KPI 共用贴源+标准层)。
 /// 照抄 S6_REPORT 范式:执行器 → mdp_stg_t8_* → transform → mdp_std_t8_*。
-/// 源:mdp_source=T8_V5_SQLSERVER(MdpSourceScopeFactory 复用 Database.json 的 t8_v5 只读连接)。
+/// 源:mdp_source=T8_V5_SQLSERVER。口令由环境变量 AIDOP_MDP_SOURCE_T8_V5_SQLSERVER_PASSWORD 提供,不读 Database.json。
 /// 实体:mdp_entity T8_KC_TZ_HEAD / T8_KC_TZ_LIST / T8_KC_DD_HEAD / T8_KC_DD_LIST / T8_KC_ZJ_LIST / T8_SYS_PELIST
 ///       (种子把 target_table_name 指向对应 mdp_stg_t8_*、sync_mode=FULL、biz_key_expr=Id)。
 /// 只读源、只写 mdp;不写 T8;FULL 全量(源表极小、re-upsert 刷新可带回字段/作废标志更新)。

+ 41 - 2
server/Plugins/Admin.NET.Plugin.AiDOP/Job/MdpSourceHealthCheckJob.cs

@@ -79,14 +79,19 @@ public class MdpSourceHealthCheckJob : IJob
             catch (Exception ex)
             {
                 src.HealthStatus = 0;
+                var code = ClassifyHealthFailure(ex);
                 var illegal = string.Equals(src.SourceType, "DB", StringComparison.OrdinalIgnoreCase)
                     ? MdpSourceScopeFactory.IllegalExtraKeys(src.DbType, src.DbExtraParams)
                     : Array.Empty<string>();
                 var prefix = illegal.Count == 0
                     ? ""
                     : $"db_extra_params 含非法关键字 {string.Join(",", illegal)},已忽略。";
-                var msg = prefix + ex.Message;
-                src.HealthMsg = msg.Length > 480 ? msg[..480] : msg;
+                src.HealthMsg = prefix + code + " " + SafeHealthMessage(ex.Message);
+                if (src.HealthMsg.Length > 480)
+                    src.HealthMsg = src.HealthMsg[..480];
+                _logger.LogWarning(
+                    "[MdpSourceHealthCheckJob] source={SourceCode} code={Code}",
+                    src.SourceCode, code);
                 fail++;
             }
 
@@ -220,4 +225,38 @@ public class MdpSourceHealthCheckJob : IJob
 
     private static string Truncate(string s, int max) =>
         string.IsNullOrEmpty(s) || s.Length <= max ? s : s[..max];
+
+    internal static string ClassifyHealthFailure(Exception ex)
+    {
+        if (ex is MdpSourceSecretException secret)
+            return secret.Code;
+        for (var current = ex; current != null; current = current.InnerException)
+        {
+            var number = current.GetType().GetProperty("Number")?.GetValue(current);
+            if (number is int n && (n == 1045 || n == 18456))
+                return "AUTH_FAILED";
+            var msg = current.Message ?? "";
+            if (msg.Contains("登录失败", StringComparison.Ordinal)
+                || msg.Contains("Login failed", StringComparison.OrdinalIgnoreCase)
+                || msg.Contains("Access denied", StringComparison.OrdinalIgnoreCase))
+                return "AUTH_FAILED";
+            if (msg.Contains("timeout", StringComparison.OrdinalIgnoreCase)
+                || msg.Contains("Unable to connect", StringComparison.OrdinalIgnoreCase)
+                || msg.Contains("network-related", StringComparison.OrdinalIgnoreCase)
+                || msg.Contains("连接", StringComparison.Ordinal))
+                return "NETWORK_FAILED";
+        }
+        return "FAILED";
+    }
+
+    internal static string SafeHealthMessage(string? message)
+    {
+        var text = message ?? "";
+        if (text.Contains("Password=", StringComparison.OrdinalIgnoreCase)
+            || text.Contains("Pwd=", StringComparison.OrdinalIgnoreCase)
+            || text.Contains("ConnectionString", StringComparison.OrdinalIgnoreCase)
+            || text.Contains("Server=", StringComparison.OrdinalIgnoreCase))
+            return "连接失败,详情见运行日志";
+        return text.Length > 400 ? text[..400] : text;
+    }
 }

+ 0 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Startup.cs

@@ -61,7 +61,6 @@ public class Startup : AppStartup
         services.AddHostedService<Admin.NET.Plugin.AiDOP.Infrastructure.EtlInstanceRegistrar>();
         // MDP 出站事件驱动:入队 Pulse → 立即推送;Period Job 仅兜底
         services.AddHostedService<MdpOutboxPushWorker>();
-        services.AddHostedService<Admin.NET.Plugin.AiDOP.DataPlatform.MdpSourceConnectionImportService>();
         services.AddHostedService<MdpHotWatchWorker>();
         services.AddHostedService<Admin.NET.Plugin.AiDOP.DataPlatform.S1Refresh.S1DashboardRebuildWorker>();
         services.AddHostedService<Admin.NET.Plugin.AiDOP.DataPlatform.MdpRebuild.ModuleRebuildWorker>();