| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240 |
- 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<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,
- 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)
- {
- 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<AdoS8DataSource> 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<AdoS8DataSource> 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);
- }
- /// <summary>按 Id + 可信作用域取行;不在作用域内一律按「不存在」处理,不泄露他租户资源是否存在。</summary>
- private async Task<AdoS8DataSource> 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();
- /// <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, 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);
- }
- }
- /// <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*([^;]*)",
- 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 == '*');
- }
|