浏览代码

fix(s8): close data-source configuration | Web 2.4.339 / server 1.0.419

- test connection: replace the endpoint-non-empty check with a real probe that opens a
  connection through the same scope factory the watch evaluator uses and runs SELECT 1;
  the old check reported SUCCESS for an unresolvable host, which made the last-check
  column untrustworthy
- failure reporting: map connection errors to fixed labels (host unresolved, refused,
  authentication, database not found, timeout, network unreachable) and never return the
  driver message, which commonly echoes the whole connection string; the service log keeps
  to id, code, tenant, factory and status for the same reason
- type contract: converge the configuration surface on SQL; the form offered HTTP and MQ,
  which the row loader rejects outright, so a saved row could never be consumed. Create and
  update now refuse anything else, and the form default follows
- create: return the generated identity instead of 0, so the caller can immediately read,
  test, update or delete the new row
- update: keep last_check_at and last_check_status, which belong to the connection test
  rather than the edit form; a full-row overwrite used to clear the last-check column
YY968XX 23 小时之前
父节点
当前提交
315019e027

+ 1 - 1
Web/package.json

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

+ 9 - 2
Web/src/views/aidop/s8/config/S8DataSourceConfigPage.vue

@@ -11,7 +11,12 @@ const columns = [
 
 const fields = [
 	{ key: 'dataSourceCode', label: '数据源编码', type: 'input', required: true },
-	{ key: 'type', label: '类型', type: 'select', required: true, options: [{ label: 'HTTP', value: 'HTTP' }, { label: 'SQL', value: 'SQL' }, { label: 'MQ', value: 'MQ' }] },
+	// S8-STEP6A-CFG-DATASRC-FIX-1(D-5):类型收敛为 SQL 单选,与后端 SupportedTypes 保持单一事实源。
+	// 原选项 HTTP / MQ **不被 S8DataSourceRowLoader 消费**(IsSupportedType 只认 SQL / API),
+	// 选中保存后 scheduler 阶段必然报 data_source_unavailable —— 属「页面允许创建不可消费配置」。
+	// API 型虽有 RowLoader 实现,但 auth_type 零消费、URL 可携带 token 而 MaskSecret 覆盖不到,
+	// 故本轮不作为正式可选项对外暴露。
+	{ key: 'type', label: '类型', type: 'select', required: true, options: [{ label: 'SQL', value: 'SQL' }] },
 	{ key: 'endpoint', label: '连接地址', type: 'input', required: true },
 	// S8-CONFIG-CLEANUP-DEMO-1:auth_type 当前 0 业务消费;仅渲染层隐藏。
 	{ key: 'authType', label: '认证方式', type: 'input', hidden: true },
@@ -20,7 +25,9 @@ const fields = [
 
 const buildDefault = () => ({
 	dataSourceCode: '',
-	type: 'HTTP',
+	// S8-STEP6A-CFG-DATASRC-FIX-1(D-5):默认值同步收敛为 SQL;原 'HTTP' 会让「新增」表单一打开
+	// 就预置一个后端将拒绝、RowLoader 也无法消费的类型。
+	type: 'SQL',
 	endpoint: '',
 	authType: '',
 	enabled: true,

+ 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.418</AssemblyVersion>
-    <FileVersion>1.0.418</FileVersion>
-    <Version>1.0.418</Version>
+    <AssemblyVersion>1.0.419</AssemblyVersion>
+    <FileVersion>1.0.419</FileVersion>
+    <Version>1.0.419</Version>
   </PropertyGroup>
 
   <ItemGroup>

+ 130 - 11
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8DataSourceService.cs

@@ -1,5 +1,8 @@
 using Admin.NET.Plugin.AiDOP.Entity.S8;
 using Admin.NET.Plugin.AiDOP.Infrastructure;
+using Admin.NET.Plugin.AiDOP.Service.S8.Rules;
+using Microsoft.Extensions.Logging;
+using System.Net.Sockets;
 using System.Text.RegularExpressions;
 
 namespace Admin.NET.Plugin.AiDOP.Service.S8;
@@ -7,8 +10,41 @@ namespace Admin.NET.Plugin.AiDOP.Service.S8;
 public class S8DataSourceService : ITransient
 {
     private readonly SqlSugarRepository<AdoS8DataSource> _rep;
+    // S8-STEP6A-CFG-DATASRC-FIX-1(D-1):真实连通性探测复用 evaluator 同款 scope 工厂,
+    // 保证 test 与 watch 运行期使用**同一** endpoint 规范化 / ConfigId / CommandTimeout 口径。
+    private readonly S8SqlSugarScopeFactory _scopeFactory;
+    private readonly ILogger<S8DataSourceService> _logger;
 
-    public S8DataSourceService(SqlSugarRepository<AdoS8DataSource> rep) => _rep = rep;
+    public S8DataSourceService(
+        SqlSugarRepository<AdoS8DataSource> rep,
+        S8SqlSugarScopeFactory scopeFactory,
+        ILogger<S8DataSourceService> logger)
+    {
+        _rep = rep;
+        _scopeFactory = scopeFactory;
+        _logger = logger;
+    }
+
+    /// <summary>
+    /// S8-STEP6A-CFG-DATASRC-FIX-1(D-5):配置面唯一 canonical type set = { SQL }。
+    /// 依据:① 三个租户既有行全部 type=SQL,从无 API 行;
+    ///       ② API 分支的 auth_type **零业务消费**(RowLoader 只把它写进 Debug 日志,从不构造 Authorization),
+    ///          故带鉴权的 API 源在当前实现下不可能成立;
+    ///       ③ API endpoint 可能在 URL query 携带 token/apikey,而 MaskSecret 只覆盖 Pwd/Password,
+    ///          且 RowLoader 会 LogDebug 完整 URL —— 按批次安全口径,API 不得进入「正式可选」状态。
+    /// 前端下拉原为 HTTP / SQL / MQ,其中 HTTP、MQ 均**不被 RowLoader 消费**(IsSupportedType 只认 SQL/API),
+    /// 存下来即产生 scheduler 阶段的 data_source_unavailable。本常量与前端选项保持单一事实源。
+    /// RowLoader 仍保留 API 分支以兼容历史行,但配置面已不可能再产出 API 行。
+    /// </summary>
+    private static readonly string[] SupportedTypes = { S8DataSourceRowLoader.SqlType };
+
+    private static string NormalizeType(string? type) => type?.Trim().ToUpperInvariant() ?? string.Empty;
+
+    private static void ValidateType(string? type)
+    {
+        if (!SupportedTypes.Contains(NormalizeType(type)))
+            throw new S8BizException($"不支持的数据源类型:{type};当前仅支持 {string.Join(" / ", SupportedTypes)}");
+    }
 
     public async Task<List<AdoS8DataSource>> ListAsync(long tenantId, long factoryId)
     {
@@ -24,6 +60,8 @@ public class S8DataSourceService : ITransient
     {
         if (string.IsNullOrWhiteSpace(body.DataSourceCode) || string.IsNullOrWhiteSpace(body.Type))
             throw new S8BizException("数据源编码和类型必填");
+        ValidateType(body.Type);
+        body.Type = NormalizeType(body.Type);
         body.TenantId = scope.TenantId;
         body.FactoryId = scope.FactoryId;
         var exists = await _rep.AsQueryable()
@@ -31,7 +69,10 @@ public class S8DataSourceService : ITransient
         if (exists) throw new S8BizException("数据源编码已存在");
         body.Id = 0;
         body.CreatedAt = DateTime.Now;
-        await _rep.InsertAsync(body);
+        // S8-STEP6A-CFG-DATASRC-FIX-1(D-3):回填自增主键。原 InsertAsync 只返回 bool,
+        // body.Id 保持 0,调用方拿到 id=0 后 GET/PUT/POST test/DELETE 一律 404。
+        // 采用仓内既有写法(同 S8ExceptionTypeService 的 AsInsertable(...).ExecuteReturnBigIdentityAsync)。
+        body.Id = await _rep.AsInsertable(body).ExecuteReturnBigIdentityAsync();
         body.Endpoint = MaskSecret(body.Endpoint);
         return body;
     }
@@ -42,6 +83,8 @@ public class S8DataSourceService : ITransient
         var e = await LoadScopedAsync(id, scope);
         if (string.IsNullOrWhiteSpace(body.DataSourceCode) || string.IsNullOrWhiteSpace(body.Type))
             throw new S8BizException("数据源编码和类型必填");
+        ValidateType(body.Type);
+        body.Type = NormalizeType(body.Type);
         var exists = await _rep.AsQueryable()
             .AnyAsync(x => x.Id != id && x.TenantId == e.TenantId && x.FactoryId == e.FactoryId && x.DataSourceCode == body.DataSourceCode);
         if (exists) throw new S8BizException("数据源编码已存在");
@@ -52,6 +95,11 @@ public class S8DataSourceService : ITransient
         body.TenantId = e.TenantId;
         body.FactoryId = e.FactoryId;
         body.CreatedAt = e.CreatedAt;
+        // S8-STEP6A-CFG-DATASRC-FIX-1(D-4):last_check_* 属**连接测试产生的系统状态**,
+        // 不是表单可编辑字段。原实现用请求体整行覆盖,导致「编辑一次备注就把最近检测结果清空」,
+        // 而列表「最近检测」列正是读这两列。此处一律沿用库中既有值。
+        body.LastCheckAt = e.LastCheckAt;
+        body.LastCheckStatus = e.LastCheckStatus;
         body.UpdatedAt = DateTime.Now;
         await _rep.UpdateAsync(body);
         body.Endpoint = MaskSecret(body.Endpoint);
@@ -71,24 +119,95 @@ public class S8DataSourceService : ITransient
             .Where(x => x.Id == id && x.TenantId == scope.TenantId && x.FactoryId == scope.FactoryId)
             .FirstAsync() ?? throw new S8NotFoundException();
 
+    /// <summary>
+    /// S8-STEP6A-CFG-DATASRC-FIX-1(D-1):真实连通性探测,取代原「endpoint 非空即 SUCCESS」的伪实现。
+    /// 原实现对不可解析主机(实测 aidopdev.local,NXDOMAIN)同样返回 SUCCESS,使「最近检测」列不可信。
+    /// 本实现与 watch 运行期同源:S8SqlSugarScopeFactory.CreateScope(同 endpoint 规范化 / ConfigId /
+    /// CommandTimeout)→ 只读 `SELECT 1`。**不读业务表、不执行 rule expression、无 DDL/DML。**
+    /// 失败一律归类为固定标签,**绝不把底层异常原文、连接串或密码回传前端 / 写入 last_check_status**。
+    /// </summary>
     public async Task<object> TestAsync(long id, S8TrustedScope scope)
     {
         var entity = await LoadScopedAsync(id, scope);
-        var success = !string.IsNullOrWhiteSpace(entity.Endpoint);
+        var (success, status, message) = await ProbeAsync(entity);
+
         entity.LastCheckAt = DateTime.Now;
-        entity.LastCheckStatus = success ? "SUCCESS" : "FAILED: endpoint is empty";
+        entity.LastCheckStatus = status;
         entity.UpdatedAt = DateTime.Now;
         await _rep.UpdateAsync(entity);
-        return new
+
+        // 只记录分类结果与数据源标识,不记录 endpoint / 连接串 / 密码。
+        _logger.LogInformation(
+            "s8_data_source_test id={Id} code={Code} tenantId={TenantId} factoryId={FactoryId} status={Status}",
+            entity.Id, entity.DataSourceCode, entity.TenantId, entity.FactoryId, status);
+
+        return new { id, success, message, entity.LastCheckAt, entity.LastCheckStatus };
+    }
+
+    private const string ProbeSql = "SELECT 1";
+
+    private async Task<(bool Success, string Status, string Message)> ProbeAsync(AdoS8DataSource entity)
+    {
+        if (string.IsNullOrWhiteSpace(entity.Endpoint))
+            return (false, "FAILED: endpoint is empty", "连接地址为空,未通过校验");
+        if (!SupportedTypes.Contains(NormalizeType(entity.Type)))
+            return (false, "FAILED: unsupported type", $"不支持的数据源类型:{entity.Type}");
+
+        var timeoutSeconds = S8EvaluatorGuard.ResolveCommandTimeoutSeconds(_logger);
+        try
+        {
+            using var db = _scopeFactory.CreateScope(
+                entity.Endpoint!, _rep.Context.CurrentConnectionConfig.DbType, timeoutSeconds);
+            await db.Ado.GetScalarAsync(ProbeSql);
+            return (true, "SUCCESS", "连接成功(已建立连接并执行 SELECT 1)");
+        }
+        catch (Exception ex)
         {
-            id,
-            success,
-            message = success ? "连接信息校验通过" : "连接地址为空,未通过校验",
-            entity.LastCheckAt,
-            entity.LastCheckStatus
-        };
+            var (status, message) = ClassifyProbeFailure(ex);
+            return (false, status, message);
+        }
+    }
+
+    /// <summary>
+    /// 把底层连接异常收敛为**固定标签 + 安全文案**。
+    /// 只读取异常的类型与关键字用于分类,**不把 ex.Message 原文写入返回值或 last_check_status** ——
+    /// 驱动异常常在 message 中回显完整连接串(含 Uid/Pwd)。
+    /// </summary>
+    private static (string Status, string Message) ClassifyProbeFailure(Exception ex)
+    {
+        var text = Flatten(ex);
+
+        if (Contains(text, "no such host", "name or service not known", "unknown host", "getaddrinfo", "name does not resolve"))
+            return ("FAILED: host unresolved", "连接失败:主机无法解析,请检查连接地址中的主机名");
+        if (Contains(text, "actively refused", "connection refused", "refused it"))
+            return ("FAILED: connection refused", "连接失败:目标主机拒绝连接,请检查端口与服务状态");
+        if (Contains(text, "access denied", "authentication", "auth_failed", "password"))
+            return ("FAILED: authentication", "连接失败:认证未通过,请检查账号或密码配置");
+        if (Contains(text, "unknown database", "database does not exist"))
+            return ("FAILED: database not found", "连接失败:目标数据库不存在,请检查库名");
+        if (ex is TimeoutException || ex is OperationCanceledException
+            || Contains(text, "timeout", "timed out"))
+            return ("FAILED: timeout", "连接失败:连接或查询超时");
+        if (ex is SocketException || Contains(text, "unable to connect", "network"))
+            return ("FAILED: network unreachable", "连接失败:网络不可达");
+
+        // S8-CFG-DATASRC-CHECKPOINT-COMMIT-1(诚实性微修):不再声称「详情见服务端日志」——
+        // 本服务的日志只记 id/code/tenantId/factoryId/status,**刻意不记录底层异常详情**
+        // (驱动异常 message 常回显完整连接串含 Uid/Pwd)。为了让旧文案成立而去记 ex.Message
+        // 会直接制造凭据泄漏,故改文案、不改日志。分类 / status / HTTP 契约 / 探测行为均不变。
+        return ("FAILED: connection error", "连接失败,请检查连接配置或联系管理员");
+    }
+
+    private static string Flatten(Exception ex)
+    {
+        var parts = new List<string>();
+        for (var cur = ex; cur != null; cur = cur.InnerException) parts.Add(cur.Message ?? string.Empty);
+        return string.Join(" | ", parts).ToLowerInvariant();
     }
 
+    private static bool Contains(string haystack, params string[] needles) =>
+        needles.Any(n => haystack.Contains(n, StringComparison.Ordinal));
+
     // BUG-13:endpoint 中的 Pwd=xxx / Password=xxx(大小写不敏感)替换为 ******,保留其它字段。
     private static readonly Regex SecretPattern = new(
         @"(?i)(Pwd|Password)\s*=\s*([^;]*)",