using Admin.NET.Core;
using Admin.NET.Plugin.AiDOP.Dto.SmartOps;
using Admin.NET.Plugin.AiDOP.Entity;
using Admin.NET.Plugin.AiDOP.Infrastructure;
using SqlSugar;
namespace Admin.NET.Plugin.AiDOP.SmartOps;
///
/// KPI 计算配置服务:CRUD + 校验 + 试算 + 发布/停用/激活(发布状态机,单生效版本事务保证)。
/// 租户显式控制(ClearFilter):配置落在"分发器运行时查找的租户"= KPI 数据租户(S5=AidopSourceTenantMap 解析),
/// 而非 API 调用者 JWT 租户;否则运行时找不到配置。SqlScript 属高危配置,接口须登录鉴权(Controller 层)。
///
public sealed class AdoSmartOpsKpiCalcConfigService : ITransient
{
private const string EngineLegacyCode = "LEGACY_CODE";
private const string EngineConfigSql = "CONFIG_SQL";
private const string EngineLegacyTvf = "LEGACY_TVF";
private const string StatusDraft = "DRAFT";
private const string StatusPublished = "PUBLISHED";
private const string StatusRetired = "RETIRED";
private static readonly string[] TenantScopedModules = { "S5", "S6", "S7" };
private readonly ISqlSugarClient _db;
private readonly KpiSqlReadOnlyExecutor _executor;
private readonly AdoSmartOpsKpiBusinessInputService _businessInput;
public AdoSmartOpsKpiCalcConfigService(ISqlSugarClient db, KpiSqlReadOnlyExecutor executor, AdoSmartOpsKpiBusinessInputService businessInput)
{
_db = db;
_executor = executor;
_businessInput = businessInput;
}
///
/// CONFIG_SQL 业务门禁:仅 BusinessInputStatus=READY_FOR_CONFIG 放行 试算/发布/激活。
/// 未就绪则明确拒绝——不执行 SQL、不写运行日志、不改版本状态/IsCurrent。
///
private async Task EnsureBusinessReadyAsync(long tenantId, string metricCode, string action)
{
var status = await _businessInput.GetStatusAsync(tenantId, metricCode);
if (status != AdoSmartOpsKpiBusinessInputService.StatusReady)
throw Oops.Bah($"业务来源未就绪(当前 {status}):{action} 需先在「业务来源登记」补齐来源系统/表/字段/SQL来源并置为 READY_FOR_CONFIG");
}
/// 解析配置应归属的租户(= 运行时 KPI 落库租户)。S5/S6/S7 走 T8 账套映射(pbxfxp→AIDOP)。
public static long ResolveKpiTenantId(string moduleCode)
{
if (TenantScopedModules.Contains((moduleCode ?? "").Trim().ToUpperInvariant()))
return AidopSourceTenantMap.ResolveTenantId("pbxfxp");
return SqlSugarConst.DefaultTenantId;
}
private ISugarQueryable Query() =>
_db.Queryable().ClearFilter();
/// 某 KPI 的全部版本(按版本号倒序)。
public async Task> GetByMetricAsync(string metricCode, string moduleCode)
{
var tenantId = ResolveKpiTenantId(moduleCode);
var list = await Query()
.Where(x => x.MetricCode == metricCode && x.TenantId == tenantId)
.OrderBy(x => x.VersionNo, OrderByType.Desc)
.ToListAsync();
return list.Select(ToDto).ToList();
}
/// 当前生效配置(PUBLISHED + IsCurrent),无则 null(= 走 legacy)。
public async Task GetActiveAsync(long tenantId, string metricCode)
{
return await Query()
.Where(x => x.MetricCode == metricCode && x.TenantId == tenantId
&& x.IsCurrent && x.PublishStatus == StatusPublished)
.FirstAsync();
}
/// 新增草稿(VersionNo = 现有最大 + 1)。
public async Task CreateDraftAsync(KpiCalcConfigUpsertDto dto, string operatorName)
{
ValidateEngineType(dto.CalcEngineType);
var tenantId = ResolveKpiTenantId(dto.ModuleCode);
var maxVer = await Query()
.Where(x => x.MetricCode == dto.MetricCode && x.TenantId == tenantId)
.MaxAsync(x => (int?)x.VersionNo) ?? 0;
var entity = new AdoSmartOpsKpiCalcConfig
{
TenantId = tenantId,
MetricCode = dto.MetricCode,
ModuleCode = dto.ModuleCode,
VersionNo = maxVer + 1,
CalcEngineType = dto.CalcEngineType,
DataSourceCode = dto.DataSourceCode,
SqlScript = dto.SqlScript,
SqlParametersJson = dto.SqlParametersJson,
TimeoutSeconds = NormalizeTimeout(dto.TimeoutSeconds),
PublishStatus = StatusDraft,
IsCurrent = false,
Remark = dto.Remark,
CreatedBy = operatorName,
CreatedAt = DateTime.Now,
};
entity.Id = await _db.Insertable(entity).ExecuteReturnBigIdentityAsync();
return ToDto(entity);
}
/// 编辑草稿(仅 DRAFT 可改;已发布/停用版本不可改,须新建版本)。
public async Task UpdateDraftAsync(long id, KpiCalcConfigUpsertDto dto, string operatorName)
{
var entity = await Query().Where(x => x.Id == id).FirstAsync()
?? throw Oops.Bah("配置版本不存在");
if (entity.PublishStatus != StatusDraft)
throw Oops.Bah("仅草稿(DRAFT)可编辑;已发布/停用版本请新建版本");
ValidateEngineType(dto.CalcEngineType);
entity.CalcEngineType = dto.CalcEngineType;
entity.DataSourceCode = dto.DataSourceCode;
entity.SqlScript = dto.SqlScript;
entity.SqlParametersJson = dto.SqlParametersJson;
entity.TimeoutSeconds = NormalizeTimeout(dto.TimeoutSeconds);
entity.Remark = dto.Remark;
entity.UpdatedBy = operatorName;
entity.UpdatedAt = DateTime.Now;
await _db.Updateable(entity).ExecuteCommandAsync();
}
/// 删除草稿(仅 DRAFT)。
public async Task DeleteAsync(long id)
{
var entity = await Query().Where(x => x.Id == id).FirstAsync()
?? throw Oops.Bah("配置版本不存在");
if (entity.PublishStatus != StatusDraft)
throw Oops.Bah("仅草稿(DRAFT)可删除;已发布版本请用停用");
await _db.Deleteable().Where(x => x.Id == id).ExecuteCommandAsync();
}
/// SQL 安全校验(多层非正则)。
public KpiSqlValidateResultDto Validate(string? sql)
{
var r = KpiSqlSecurityValidator.Validate(sql);
return new KpiSqlValidateResultDto
{
Ok = r.Ok,
ValidationStatus = r.Ok ? "VALID" : "INVALID",
ErrorCode = r.ErrorCode,
ErrorMessage = r.ErrorMessage,
ReferencedTables = r.ReferencedTables,
};
}
/// 试算(服务端重校验 + 只读执行;不写正式 KPI 值)。
public async Task PreviewAsync(KpiSqlPreviewDto dto, CancellationToken ct)
{
var bizDate = (dto.BizDate ?? DateTime.Today.AddDays(-1)).Date;
var res = new KpiSqlPreviewResultDto
{
MetricCode = dto.MetricCode,
EngineType = EngineConfigSql,
DataSourceCode = dto.DataSourceCode,
BizDate = bizDate.ToString("yyyy-MM-dd"),
};
var val = KpiSqlSecurityValidator.Validate(dto.SqlScript);
res.ValidationStatus = val.Ok ? "VALID" : "INVALID";
if (!val.Ok)
{
res.Ok = false;
res.ResultStatus = "FAILED";
res.ErrorCode = val.ErrorCode;
res.ErrorMessage = val.ErrorMessage;
return res;
}
var tenantId = ResolveKpiTenantId(dto.ModuleCode);
// 业务门禁:试算是真实数据只读执行,未 READY 直接拒绝(不落只读事务、不占连接)。
await EnsureBusinessReadyAsync(tenantId, dto.MetricCode, "试算");
var pars = new KpiSqlRunParams
{
TenantId = tenantId,
FactoryId = 1,
ModuleCode = dto.ModuleCode,
MetricCode = dto.MetricCode,
BizDate = bizDate,
PeriodStart = bizDate,
PeriodEnd = bizDate.AddDays(1).AddSeconds(-1),
SourceZtid = "pbxfxp",
};
var exec = await _executor.ExecuteAsync(dto.DataSourceCode, dto.SqlScript!, dto.TimeoutSeconds, true, pars, ct);
res.Ok = exec.Status != "FAILED";
res.ResultStatus = exec.Status;
res.DurationMs = exec.DurationMs;
res.RowCount = exec.RowCount;
res.MetricValue = exec.MetricValue;
res.NumeratorValue = exec.NumeratorValue;
res.DenominatorValue = exec.DenominatorValue;
res.ResultMessage = exec.ResultMessage;
res.ErrorCode = exec.ErrorCode;
res.ErrorMessage = exec.ErrorMessage;
res.ExecutedParameters =
$"tenant_id={pars.TenantId}, factory_id={pars.FactoryId}, module_code={pars.ModuleCode}, " +
$"metric_code={pars.MetricCode}, biz_date={pars.BizDate:yyyy-MM-dd}, ztid={pars.SourceZtid}";
return res;
}
/// 发布:事务内校验→旧 current 置 0→本版 PUBLISHED+IsCurrent=1(保证单生效版本)。
public async Task PublishAsync(long id, string operatorName)
{
var entity = await Query().Where(x => x.Id == id).FirstAsync()
?? throw Oops.Bah("配置版本不存在");
if (entity.PublishStatus == StatusRetired)
throw Oops.Bah("已停用版本不可发布,请新建版本");
// CONFIG_SQL 发布前必须再过一次安全校验
if (entity.CalcEngineType == EngineConfigSql)
{
var val = KpiSqlSecurityValidator.Validate(entity.SqlScript);
if (!val.Ok) throw Oops.Bah($"SQL 安全校验未通过:{val.ErrorCode} {val.ErrorMessage}");
if (string.IsNullOrWhiteSpace(entity.DataSourceCode))
throw Oops.Bah("CONFIG_SQL 必须指定数据源");
await EnsureBusinessReadyAsync(entity.TenantId ?? 0, entity.MetricCode, "发布");
}
var tenantId = entity.TenantId;
var tran = await _db.AsTenant().UseTranAsync(async () =>
{
await _db.Updateable()
.SetColumns(x => new AdoSmartOpsKpiCalcConfig { IsCurrent = false })
.Where(x => x.MetricCode == entity.MetricCode && x.TenantId == tenantId && x.Id != id && x.IsCurrent)
.ExecuteCommandAsync();
await _db.Updateable()
.SetColumns(x => new AdoSmartOpsKpiCalcConfig
{
PublishStatus = StatusPublished, IsCurrent = true,
PublishedBy = operatorName, PublishedAt = DateTime.Now,
})
.Where(x => x.Id == id)
.ExecuteCommandAsync();
});
if (!tran.IsSuccess) throw tran.ErrorException;
}
/// 激活/回滚:把某历史版本设为当前生效(其它同 KPI 版本 IsCurrent 置 0),事务内。仅对已发布版本。
public async Task ActivateAsync(long id, string operatorName)
{
var entity = await Query().Where(x => x.Id == id).FirstAsync()
?? throw Oops.Bah("配置版本不存在");
if (entity.PublishStatus != StatusPublished)
throw Oops.Bah("只能激活已发布(PUBLISHED)版本;回滚是激活历史已发布版本,不是复制重发");
if (entity.CalcEngineType == EngineConfigSql)
await EnsureBusinessReadyAsync(entity.TenantId ?? 0, entity.MetricCode, "激活");
var tenantId = entity.TenantId;
var tran = await _db.AsTenant().UseTranAsync(async () =>
{
await _db.Updateable()
.SetColumns(x => new AdoSmartOpsKpiCalcConfig { IsCurrent = false })
.Where(x => x.MetricCode == entity.MetricCode && x.TenantId == tenantId && x.Id != id && x.IsCurrent)
.ExecuteCommandAsync();
await _db.Updateable()
.SetColumns(x => new AdoSmartOpsKpiCalcConfig { IsCurrent = true, UpdatedBy = operatorName, UpdatedAt = DateTime.Now })
.Where(x => x.Id == id)
.ExecuteCommandAsync();
});
if (!tran.IsSuccess) throw tran.ErrorException;
}
/// 停用(RETIRED,IsCurrent=0)。停用当前生效版本后无 current → 运行时回落 legacy。
public async Task RetireAsync(long id, string operatorName)
{
var entity = await Query().Where(x => x.Id == id).FirstAsync()
?? throw Oops.Bah("配置版本不存在");
await _db.Updateable()
.SetColumns(x => new AdoSmartOpsKpiCalcConfig
{
PublishStatus = StatusRetired, IsCurrent = false,
RetiredBy = operatorName, RetiredAt = DateTime.Now,
})
.Where(x => x.Id == id)
.ExecuteCommandAsync();
}
/// 已登记数据源列表(第一版仅本地中台库)。
public List