|
|
@@ -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*([^;]*)",
|