|
@@ -1,51 +1,53 @@
|
|
|
using Admin.NET.Plugin.AiDOP.Entity.S8;
|
|
using Admin.NET.Plugin.AiDOP.Entity.S8;
|
|
|
using Admin.NET.Plugin.AiDOP.Infrastructure;
|
|
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;
|
|
using System.Text.RegularExpressions;
|
|
|
|
|
|
|
|
namespace Admin.NET.Plugin.AiDOP.Service.S8;
|
|
namespace Admin.NET.Plugin.AiDOP.Service.S8;
|
|
|
|
|
|
|
|
|
|
+/// <summary>
|
|
|
|
|
+/// S8 数据源配置服务。
|
|
|
|
|
+///
|
|
|
|
|
+/// <b>S8-LEGACY-RUNTIME-RETIREMENT-1:本服务已退化为只读。</b>
|
|
|
|
|
+///
|
|
|
|
|
+/// 产品裁决:S8 = 数据中台**消费层**,不是数据接入层。
|
|
|
|
|
+/// <code>
|
|
|
|
|
+/// Data Platform → Governed Dataset → S8 Provider → Canonical Contract → Watch Rule
|
|
|
|
|
+/// </code>
|
|
|
|
|
+/// 物理数据源(连接串 / 主机 / 端口 / 凭据)的治理权归数据平台,不由租户 S8 管理员维护。
|
|
|
|
|
+///
|
|
|
|
|
+/// <b>为什么不能只隐藏 UI</b>:<c>ado_s8_data_source.endpoint</c> 存的是**含凭据的真实连接串**,
|
|
|
|
|
+/// 而 API 仍可被直接调用。写能力留着就等于「入口看不见但仍可写库、仍可让 S8 发起对外连接」。
|
|
|
|
|
+/// 故在服务层(写入的唯一收敛点)统一拒绝。
|
|
|
|
|
+///
|
|
|
|
|
+/// <b>为什么连接测试也退役</b>:<c>TestAsync</c> 虽只写 <c>last_check_*</c>,但它会用库里的 endpoint
|
|
|
|
|
+/// **真实建立外部数据库连接** —— 那正是本批要终止的「S8 维护物理连接」能力,
|
|
|
|
|
+/// 比单纯的行更新更需要关掉。随之移除 <c>S8SqlSugarScopeFactory</c> 依赖:
|
|
|
|
|
+/// 本服务从此**在结构上**不具备发起外连的能力,而不只是"约定不用"。
|
|
|
|
|
+///
|
|
|
|
|
+/// <b>保留的部分</b>:
|
|
|
|
|
+/// · <see cref="ListAsync"/> —— 存量 Legacy 规则仍绑着这些行,排障与历史核对需要能看到(endpoint 仍脱敏);
|
|
|
|
|
+/// · 写方法签名 —— 硬约束,<c>S8TenantIsolationContractTests</c> 用反射断言带
|
|
|
|
|
+/// <c>S8TrustedScope</c> 的写入口存在、且无 scope 的旧重载不存在;
|
|
|
|
|
+/// · <c>ado_s8_data_source</c> 表 / <c>S8DataSourceRowLoader</c> / <c>S8LegacySqlDataProvider</c> ——
|
|
|
|
|
+/// 本批只做 Runtime Retirement,物理清理留待后续独立批次。
|
|
|
|
|
+/// </summary>
|
|
|
public class S8DataSourceService : ITransient
|
|
public class S8DataSourceService : ITransient
|
|
|
{
|
|
{
|
|
|
private readonly SqlSugarRepository<AdoS8DataSource> _rep;
|
|
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)
|
|
|
|
|
|
|
+ public S8DataSourceService(SqlSugarRepository<AdoS8DataSource> rep)
|
|
|
{
|
|
{
|
|
|
_rep = rep;
|
|
_rep = rep;
|
|
|
- _scopeFactory = scopeFactory;
|
|
|
|
|
- _logger = logger;
|
|
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /// <summary>数据源写入口退役后的统一文案。与 Rule 侧退役文案分开:二者是两条不同的产品裁决。</summary>
|
|
|
|
|
+ public const string RetiredMessage =
|
|
|
|
|
+ "S8 已切换为数据中台 Dataset 消费模式,物理数据源由数据平台治理,"
|
|
|
|
|
+ + "S8 侧不再提供数据源的新增 / 修改 / 删除 / 连接测试。";
|
|
|
|
|
+
|
|
|
/// <summary>
|
|
/// <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 行。
|
|
|
|
|
|
|
+ /// 只读列出本作用域的数据源。endpoint 中的 Pwd / Password 一律脱敏后返回。
|
|
|
/// </summary>
|
|
/// </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)
|
|
public async Task<List<AdoS8DataSource>> ListAsync(long tenantId, long factoryId)
|
|
|
{
|
|
{
|
|
|
var rows = await _rep.AsQueryable()
|
|
var rows = await _rep.AsQueryable()
|
|
@@ -55,158 +57,26 @@ public class S8DataSourceService : ITransient
|
|
|
return rows;
|
|
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();
|
|
|
|
|
|
|
+ // ================================================================================
|
|
|
|
|
+ // 写入口:全部退役。
|
|
|
|
|
+ //
|
|
|
|
|
+ // ⚠️ 抛出发生在**任何 DB 访问之前**:不做 LoadScopedAsync、不做重复性查询。
|
|
|
|
|
+ // 这既保证零 DB 触碰,也保证任意 id(含越权 id)一律 410 而非 404
|
|
|
|
|
+ // ——「这个能力没了」优先于「这条记录不属于你」,
|
|
|
|
|
+ // 避免用越权探测反推他租户资源是否存在。
|
|
|
|
|
+ // ================================================================================
|
|
|
|
|
|
|
|
- /// <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);
|
|
|
|
|
|
|
+ public Task<AdoS8DataSource> CreateAsync(AdoS8DataSource body, S8TrustedScope scope) =>
|
|
|
|
|
+ throw new S8WriteRetiredException(RetiredMessage);
|
|
|
|
|
|
|
|
- entity.LastCheckAt = DateTime.Now;
|
|
|
|
|
- entity.LastCheckStatus = status;
|
|
|
|
|
- entity.UpdatedAt = DateTime.Now;
|
|
|
|
|
- await _rep.UpdateAsync(entity);
|
|
|
|
|
|
|
+ public Task<AdoS8DataSource> UpdateAsync(long id, AdoS8DataSource body, S8TrustedScope scope) =>
|
|
|
|
|
+ throw new S8WriteRetiredException(RetiredMessage);
|
|
|
|
|
|
|
|
- // 只记录分类结果与数据源标识,不记录 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 };
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ public Task DeleteAsync(long id, S8TrustedScope scope) =>
|
|
|
|
|
+ throw new S8WriteRetiredException(RetiredMessage);
|
|
|
|
|
|
|
|
- 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));
|
|
|
|
|
|
|
+ public Task<object> TestAsync(long id, S8TrustedScope scope) =>
|
|
|
|
|
+ throw new S8WriteRetiredException(RetiredMessage);
|
|
|
|
|
|
|
|
// BUG-13:endpoint 中的 Pwd=xxx / Password=xxx(大小写不敏感)替换为 ******,保留其它字段。
|
|
// BUG-13:endpoint 中的 Pwd=xxx / Password=xxx(大小写不敏感)替换为 ******,保留其它字段。
|
|
|
private static readonly Regex SecretPattern = new(
|
|
private static readonly Regex SecretPattern = new(
|
|
@@ -218,23 +88,4 @@ public class S8DataSourceService : ITransient
|
|
|
if (string.IsNullOrWhiteSpace(endpoint)) return endpoint;
|
|
if (string.IsNullOrWhiteSpace(endpoint)) return endpoint;
|
|
|
return SecretPattern.Replace(endpoint, m => $"{m.Groups[1].Value}=******");
|
|
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 == '*');
|
|
|
|
|
}
|
|
}
|