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; public class S8DataSourceService : ITransient { private readonly SqlSugarRepository _rep; // S8-STEP6A-CFG-DATASRC-FIX-1(D-1):真实连通性探测复用 evaluator 同款 scope 工厂, // 保证 test 与 watch 运行期使用**同一** endpoint 规范化 / ConfigId / CommandTimeout 口径。 private readonly S8SqlSugarScopeFactory _scopeFactory; private readonly ILogger _logger; public S8DataSourceService( SqlSugarRepository rep, S8SqlSugarScopeFactory scopeFactory, ILogger logger) { _rep = rep; _scopeFactory = scopeFactory; _logger = logger; } /// /// 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 行。 /// 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> ListAsync(long tenantId, long factoryId) { var rows = await _rep.AsQueryable() .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId) .ToListAsync(); foreach (var r in rows) r.Endpoint = MaskSecret(r.Endpoint); return rows; } // S8-TENANT-FACTORY-P0-CLOSURE-1:归属一律由服务端可信作用域盖章,忽略 body.TenantId / body.FactoryId。 public async Task CreateAsync(AdoS8DataSource body, S8TrustedScope scope) { 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() .AnyAsync(x => x.TenantId == body.TenantId && x.FactoryId == body.FactoryId && x.DataSourceCode == body.DataSourceCode); if (exists) throw new S8BizException("数据源编码已存在"); body.Id = 0; body.CreatedAt = DateTime.Now; // 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; } // S8-TENANT-FACTORY-P0-CLOSURE-1:按 Id + 可信作用域绑行;越权 Id 视为不存在,归属不可被 body 改写。 public async Task UpdateAsync(long id, AdoS8DataSource body, S8TrustedScope scope) { 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("数据源编码已存在"); // 入参 endpoint 含掩码占位符(Pwd=****** / Password=******)时保留旧值的真实密码段,避免前端 // 回填脱敏值后误覆盖。Endpoint 全空时也不覆盖原密码。 body.Endpoint = MergeEndpointPreservingSecret(body.Endpoint, e.Endpoint); body.Id = id; 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); return body; } // S8-TENANT-FACTORY-P0-CLOSURE-1:删除必须先按可信作用域绑行,禁止裸 DeleteByIdAsync(id)。 public async Task DeleteAsync(long id, S8TrustedScope scope) { var e = await LoadScopedAsync(id, scope); await _rep.DeleteByIdAsync(e.Id); } /// 按 Id + 可信作用域取行;不在作用域内一律按「不存在」处理,不泄露他租户资源是否存在。 private async Task LoadScopedAsync(long id, S8TrustedScope scope) => await _rep.AsQueryable() .Where(x => x.Id == id && x.TenantId == scope.TenantId && x.FactoryId == scope.FactoryId) .FirstAsync() ?? throw new S8NotFoundException(); /// /// 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**。 /// public async Task TestAsync(long id, S8TrustedScope scope) { var entity = await LoadScopedAsync(id, scope); var (success, status, message) = await ProbeAsync(entity); entity.LastCheckAt = DateTime.Now; entity.LastCheckStatus = status; entity.UpdatedAt = DateTime.Now; await _rep.UpdateAsync(entity); // 只记录分类结果与数据源标识,不记录 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) { var (status, message) = ClassifyProbeFailure(ex); return (false, status, message); } } /// /// 把底层连接异常收敛为**固定标签 + 安全文案**。 /// 只读取异常的类型与关键字用于分类,**不把 ex.Message 原文写入返回值或 last_check_status** —— /// 驱动异常常在 message 中回显完整连接串(含 Uid/Pwd)。 /// 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(); 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*([^;]*)", RegexOptions.Compiled); private static string? MaskSecret(string? endpoint) { if (string.IsNullOrWhiteSpace(endpoint)) return endpoint; return SecretPattern.Replace(endpoint, m => $"{m.Groups[1].Value}=******"); } private static string? MergeEndpointPreservingSecret(string? incoming, string? existing) { if (string.IsNullOrWhiteSpace(incoming)) return existing; if (string.IsNullOrWhiteSpace(existing)) return incoming; // 提取旧 endpoint 中的真实密码值(首个匹配为准) var oldMatch = SecretPattern.Match(existing); if (!oldMatch.Success) return incoming; var realSecret = oldMatch.Groups[2].Value; // 把入参里 Pwd=****** 之类的占位还原为真实密码 return SecretPattern.Replace(incoming, m => { var v = m.Groups[2].Value; return IsMaskedPlaceholder(v) ? $"{m.Groups[1].Value}={realSecret}" : m.Value; }); } private static bool IsMaskedPlaceholder(string? v) => !string.IsNullOrEmpty(v) && v.All(c => c == '*'); }