S8DataSourceService.cs 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240
  1. using Admin.NET.Plugin.AiDOP.Entity.S8;
  2. using Admin.NET.Plugin.AiDOP.Infrastructure;
  3. using Admin.NET.Plugin.AiDOP.Service.S8.Rules;
  4. using Microsoft.Extensions.Logging;
  5. using System.Net.Sockets;
  6. using System.Text.RegularExpressions;
  7. namespace Admin.NET.Plugin.AiDOP.Service.S8;
  8. public class S8DataSourceService : ITransient
  9. {
  10. private readonly SqlSugarRepository<AdoS8DataSource> _rep;
  11. // S8-STEP6A-CFG-DATASRC-FIX-1(D-1):真实连通性探测复用 evaluator 同款 scope 工厂,
  12. // 保证 test 与 watch 运行期使用**同一** endpoint 规范化 / ConfigId / CommandTimeout 口径。
  13. private readonly S8SqlSugarScopeFactory _scopeFactory;
  14. private readonly ILogger<S8DataSourceService> _logger;
  15. public S8DataSourceService(
  16. SqlSugarRepository<AdoS8DataSource> rep,
  17. S8SqlSugarScopeFactory scopeFactory,
  18. ILogger<S8DataSourceService> logger)
  19. {
  20. _rep = rep;
  21. _scopeFactory = scopeFactory;
  22. _logger = logger;
  23. }
  24. /// <summary>
  25. /// S8-STEP6A-CFG-DATASRC-FIX-1(D-5):配置面唯一 canonical type set = { SQL }。
  26. /// 依据:① 三个租户既有行全部 type=SQL,从无 API 行;
  27. /// ② API 分支的 auth_type **零业务消费**(RowLoader 只把它写进 Debug 日志,从不构造 Authorization),
  28. /// 故带鉴权的 API 源在当前实现下不可能成立;
  29. /// ③ API endpoint 可能在 URL query 携带 token/apikey,而 MaskSecret 只覆盖 Pwd/Password,
  30. /// 且 RowLoader 会 LogDebug 完整 URL —— 按批次安全口径,API 不得进入「正式可选」状态。
  31. /// 前端下拉原为 HTTP / SQL / MQ,其中 HTTP、MQ 均**不被 RowLoader 消费**(IsSupportedType 只认 SQL/API),
  32. /// 存下来即产生 scheduler 阶段的 data_source_unavailable。本常量与前端选项保持单一事实源。
  33. /// RowLoader 仍保留 API 分支以兼容历史行,但配置面已不可能再产出 API 行。
  34. /// </summary>
  35. private static readonly string[] SupportedTypes = { S8DataSourceRowLoader.SqlType };
  36. private static string NormalizeType(string? type) => type?.Trim().ToUpperInvariant() ?? string.Empty;
  37. private static void ValidateType(string? type)
  38. {
  39. if (!SupportedTypes.Contains(NormalizeType(type)))
  40. throw new S8BizException($"不支持的数据源类型:{type};当前仅支持 {string.Join(" / ", SupportedTypes)}");
  41. }
  42. public async Task<List<AdoS8DataSource>> ListAsync(long tenantId, long factoryId)
  43. {
  44. var rows = await _rep.AsQueryable()
  45. .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId)
  46. .ToListAsync();
  47. foreach (var r in rows) r.Endpoint = MaskSecret(r.Endpoint);
  48. return rows;
  49. }
  50. // S8-TENANT-FACTORY-P0-CLOSURE-1:归属一律由服务端可信作用域盖章,忽略 body.TenantId / body.FactoryId。
  51. public async Task<AdoS8DataSource> CreateAsync(AdoS8DataSource body, S8TrustedScope scope)
  52. {
  53. if (string.IsNullOrWhiteSpace(body.DataSourceCode) || string.IsNullOrWhiteSpace(body.Type))
  54. throw new S8BizException("数据源编码和类型必填");
  55. ValidateType(body.Type);
  56. body.Type = NormalizeType(body.Type);
  57. body.TenantId = scope.TenantId;
  58. body.FactoryId = scope.FactoryId;
  59. var exists = await _rep.AsQueryable()
  60. .AnyAsync(x => x.TenantId == body.TenantId && x.FactoryId == body.FactoryId && x.DataSourceCode == body.DataSourceCode);
  61. if (exists) throw new S8BizException("数据源编码已存在");
  62. body.Id = 0;
  63. body.CreatedAt = DateTime.Now;
  64. // S8-STEP6A-CFG-DATASRC-FIX-1(D-3):回填自增主键。原 InsertAsync 只返回 bool,
  65. // body.Id 保持 0,调用方拿到 id=0 后 GET/PUT/POST test/DELETE 一律 404。
  66. // 采用仓内既有写法(同 S8ExceptionTypeService 的 AsInsertable(...).ExecuteReturnBigIdentityAsync)。
  67. body.Id = await _rep.AsInsertable(body).ExecuteReturnBigIdentityAsync();
  68. body.Endpoint = MaskSecret(body.Endpoint);
  69. return body;
  70. }
  71. // S8-TENANT-FACTORY-P0-CLOSURE-1:按 Id + 可信作用域绑行;越权 Id 视为不存在,归属不可被 body 改写。
  72. public async Task<AdoS8DataSource> UpdateAsync(long id, AdoS8DataSource body, S8TrustedScope scope)
  73. {
  74. var e = await LoadScopedAsync(id, scope);
  75. if (string.IsNullOrWhiteSpace(body.DataSourceCode) || string.IsNullOrWhiteSpace(body.Type))
  76. throw new S8BizException("数据源编码和类型必填");
  77. ValidateType(body.Type);
  78. body.Type = NormalizeType(body.Type);
  79. var exists = await _rep.AsQueryable()
  80. .AnyAsync(x => x.Id != id && x.TenantId == e.TenantId && x.FactoryId == e.FactoryId && x.DataSourceCode == body.DataSourceCode);
  81. if (exists) throw new S8BizException("数据源编码已存在");
  82. // 入参 endpoint 含掩码占位符(Pwd=****** / Password=******)时保留旧值的真实密码段,避免前端
  83. // 回填脱敏值后误覆盖。Endpoint 全空时也不覆盖原密码。
  84. body.Endpoint = MergeEndpointPreservingSecret(body.Endpoint, e.Endpoint);
  85. body.Id = id;
  86. body.TenantId = e.TenantId;
  87. body.FactoryId = e.FactoryId;
  88. body.CreatedAt = e.CreatedAt;
  89. // S8-STEP6A-CFG-DATASRC-FIX-1(D-4):last_check_* 属**连接测试产生的系统状态**,
  90. // 不是表单可编辑字段。原实现用请求体整行覆盖,导致「编辑一次备注就把最近检测结果清空」,
  91. // 而列表「最近检测」列正是读这两列。此处一律沿用库中既有值。
  92. body.LastCheckAt = e.LastCheckAt;
  93. body.LastCheckStatus = e.LastCheckStatus;
  94. body.UpdatedAt = DateTime.Now;
  95. await _rep.UpdateAsync(body);
  96. body.Endpoint = MaskSecret(body.Endpoint);
  97. return body;
  98. }
  99. // S8-TENANT-FACTORY-P0-CLOSURE-1:删除必须先按可信作用域绑行,禁止裸 DeleteByIdAsync(id)。
  100. public async Task DeleteAsync(long id, S8TrustedScope scope)
  101. {
  102. var e = await LoadScopedAsync(id, scope);
  103. await _rep.DeleteByIdAsync(e.Id);
  104. }
  105. /// <summary>按 Id + 可信作用域取行;不在作用域内一律按「不存在」处理,不泄露他租户资源是否存在。</summary>
  106. private async Task<AdoS8DataSource> LoadScopedAsync(long id, S8TrustedScope scope) =>
  107. await _rep.AsQueryable()
  108. .Where(x => x.Id == id && x.TenantId == scope.TenantId && x.FactoryId == scope.FactoryId)
  109. .FirstAsync() ?? throw new S8NotFoundException();
  110. /// <summary>
  111. /// S8-STEP6A-CFG-DATASRC-FIX-1(D-1):真实连通性探测,取代原「endpoint 非空即 SUCCESS」的伪实现。
  112. /// 原实现对不可解析主机(实测 aidopdev.local,NXDOMAIN)同样返回 SUCCESS,使「最近检测」列不可信。
  113. /// 本实现与 watch 运行期同源:S8SqlSugarScopeFactory.CreateScope(同 endpoint 规范化 / ConfigId /
  114. /// CommandTimeout)→ 只读 `SELECT 1`。**不读业务表、不执行 rule expression、无 DDL/DML。**
  115. /// 失败一律归类为固定标签,**绝不把底层异常原文、连接串或密码回传前端 / 写入 last_check_status**。
  116. /// </summary>
  117. public async Task<object> TestAsync(long id, S8TrustedScope scope)
  118. {
  119. var entity = await LoadScopedAsync(id, scope);
  120. var (success, status, message) = await ProbeAsync(entity);
  121. entity.LastCheckAt = DateTime.Now;
  122. entity.LastCheckStatus = status;
  123. entity.UpdatedAt = DateTime.Now;
  124. await _rep.UpdateAsync(entity);
  125. // 只记录分类结果与数据源标识,不记录 endpoint / 连接串 / 密码。
  126. _logger.LogInformation(
  127. "s8_data_source_test id={Id} code={Code} tenantId={TenantId} factoryId={FactoryId} status={Status}",
  128. entity.Id, entity.DataSourceCode, entity.TenantId, entity.FactoryId, status);
  129. return new { id, success, message, entity.LastCheckAt, entity.LastCheckStatus };
  130. }
  131. private const string ProbeSql = "SELECT 1";
  132. private async Task<(bool Success, string Status, string Message)> ProbeAsync(AdoS8DataSource entity)
  133. {
  134. if (string.IsNullOrWhiteSpace(entity.Endpoint))
  135. return (false, "FAILED: endpoint is empty", "连接地址为空,未通过校验");
  136. if (!SupportedTypes.Contains(NormalizeType(entity.Type)))
  137. return (false, "FAILED: unsupported type", $"不支持的数据源类型:{entity.Type}");
  138. var timeoutSeconds = S8EvaluatorGuard.ResolveCommandTimeoutSeconds(_logger);
  139. try
  140. {
  141. using var db = _scopeFactory.CreateScope(
  142. entity.Endpoint!, _rep.Context.CurrentConnectionConfig.DbType, timeoutSeconds);
  143. await db.Ado.GetScalarAsync(ProbeSql);
  144. return (true, "SUCCESS", "连接成功(已建立连接并执行 SELECT 1)");
  145. }
  146. catch (Exception ex)
  147. {
  148. var (status, message) = ClassifyProbeFailure(ex);
  149. return (false, status, message);
  150. }
  151. }
  152. /// <summary>
  153. /// 把底层连接异常收敛为**固定标签 + 安全文案**。
  154. /// 只读取异常的类型与关键字用于分类,**不把 ex.Message 原文写入返回值或 last_check_status** ——
  155. /// 驱动异常常在 message 中回显完整连接串(含 Uid/Pwd)。
  156. /// </summary>
  157. private static (string Status, string Message) ClassifyProbeFailure(Exception ex)
  158. {
  159. var text = Flatten(ex);
  160. if (Contains(text, "no such host", "name or service not known", "unknown host", "getaddrinfo", "name does not resolve"))
  161. return ("FAILED: host unresolved", "连接失败:主机无法解析,请检查连接地址中的主机名");
  162. if (Contains(text, "actively refused", "connection refused", "refused it"))
  163. return ("FAILED: connection refused", "连接失败:目标主机拒绝连接,请检查端口与服务状态");
  164. if (Contains(text, "access denied", "authentication", "auth_failed", "password"))
  165. return ("FAILED: authentication", "连接失败:认证未通过,请检查账号或密码配置");
  166. if (Contains(text, "unknown database", "database does not exist"))
  167. return ("FAILED: database not found", "连接失败:目标数据库不存在,请检查库名");
  168. if (ex is TimeoutException || ex is OperationCanceledException
  169. || Contains(text, "timeout", "timed out"))
  170. return ("FAILED: timeout", "连接失败:连接或查询超时");
  171. if (ex is SocketException || Contains(text, "unable to connect", "network"))
  172. return ("FAILED: network unreachable", "连接失败:网络不可达");
  173. // S8-CFG-DATASRC-CHECKPOINT-COMMIT-1(诚实性微修):不再声称「详情见服务端日志」——
  174. // 本服务的日志只记 id/code/tenantId/factoryId/status,**刻意不记录底层异常详情**
  175. // (驱动异常 message 常回显完整连接串含 Uid/Pwd)。为了让旧文案成立而去记 ex.Message
  176. // 会直接制造凭据泄漏,故改文案、不改日志。分类 / status / HTTP 契约 / 探测行为均不变。
  177. return ("FAILED: connection error", "连接失败,请检查连接配置或联系管理员");
  178. }
  179. private static string Flatten(Exception ex)
  180. {
  181. var parts = new List<string>();
  182. for (var cur = ex; cur != null; cur = cur.InnerException) parts.Add(cur.Message ?? string.Empty);
  183. return string.Join(" | ", parts).ToLowerInvariant();
  184. }
  185. private static bool Contains(string haystack, params string[] needles) =>
  186. needles.Any(n => haystack.Contains(n, StringComparison.Ordinal));
  187. // BUG-13:endpoint 中的 Pwd=xxx / Password=xxx(大小写不敏感)替换为 ******,保留其它字段。
  188. private static readonly Regex SecretPattern = new(
  189. @"(?i)(Pwd|Password)\s*=\s*([^;]*)",
  190. RegexOptions.Compiled);
  191. private static string? MaskSecret(string? endpoint)
  192. {
  193. if (string.IsNullOrWhiteSpace(endpoint)) return endpoint;
  194. return SecretPattern.Replace(endpoint, m => $"{m.Groups[1].Value}=******");
  195. }
  196. private static string? MergeEndpointPreservingSecret(string? incoming, string? existing)
  197. {
  198. if (string.IsNullOrWhiteSpace(incoming)) return existing;
  199. if (string.IsNullOrWhiteSpace(existing)) return incoming;
  200. // 提取旧 endpoint 中的真实密码值(首个匹配为准)
  201. var oldMatch = SecretPattern.Match(existing);
  202. if (!oldMatch.Success) return incoming;
  203. var realSecret = oldMatch.Groups[2].Value;
  204. // 把入参里 Pwd=****** 之类的占位还原为真实密码
  205. return SecretPattern.Replace(incoming, m =>
  206. {
  207. var v = m.Groups[2].Value;
  208. return IsMaskedPlaceholder(v) ? $"{m.Groups[1].Value}={realSecret}" : m.Value;
  209. });
  210. }
  211. private static bool IsMaskedPlaceholder(string? v) =>
  212. !string.IsNullOrEmpty(v) && v.All(c => c == '*');
  213. }