using Admin.NET.Plugin.AiDOP.DataPlatform.S0Dim; using Admin.NET.Plugin.AiDOP.Infrastructure; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; namespace Admin.NET.Plugin.AiDOP.Controllers.S0; /// /// S0 主数据维度供给层的运维端点:source → mdp_stg_s0_*dim_*。 /// /// 选址说明(刻意不挂看板控制器): /// /// AidopKanbanControllermdp/refresh/{moduleCode} 是 S1–S7 的硬编码 switch, /// 且该文件是多模块共享文件; /// MdpRebuildScope 的模块白名单为 S1–S7/S9,其阶段模型含 DWD/KPI,S0 没有这两级; /// 看板控制器用的是 AidopTenantScope,与 S0 的 是两套租户契约。 /// /// /// 租户:一律经 解析(**无默认回落**, /// 超管未选目标租户直接拒绝)。本控制器**不读也不写任何 S0 业务主表**,只读源、写 stg/dim。 /// /// 授权:。**不可删** —— /// 平台 JwtHandler 对未登记到 SysMenu 的路由默认放行, /// 去掉这道门即等于「登录即可触发本租户 4 张 dim 表的 DELETE + 全量重建」。 /// [ApiController] [Route("api/s0/dim")] [Authorize] [AdoS0DimOpsAuthorize] [NonUnify] public class AdoS0DimRefreshController : ControllerBase { private readonly S0DimRefreshService _service; /// 构造。 public AdoS0DimRefreshController(S0DimRefreshService service) => _service = service; /// 刷新当前租户的全部 S0 维度(FULL replace)。 [HttpPost("refresh")] public Task RefreshAllAsync(CancellationToken cancellationToken) => RefreshCoreAsync(null, cancellationToken); /// 刷新当前租户的单个维度。key ∈ WORK_CENTER | DEPARTMENT | LOCATION | LOCATION_SHELF。 [HttpPost("refresh/{key}")] public Task RefreshOneAsync(string key, CancellationToken cancellationToken) => RefreshCoreAsync(key, cancellationToken); /// 只读对账(不改数据)。key 省略表示全部。 [HttpGet("reconcile")] public async Task ReconcileAsync([FromQuery] string? key, CancellationToken cancellationToken) { if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!; try { var list = await _service.ReconcileAsync(tenantId, key, cancellationToken); return Ok(new { tenantId = tenantId.ToString(), items = list }); } catch (InvalidOperationException ex) { return AdoS0ApiErrors.InvalidRequest(ex.Message); } } /// 当前租户最近一次刷新的运行记录。 [HttpGet("last-run")] public async Task LastRunAsync(CancellationToken cancellationToken) { if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!; var row = await _service.LastRunAsync(tenantId, cancellationToken); return Ok(new { tenantId = tenantId.ToString(), lastRun = row }); } /// /// 维度清单(只读元数据,便于运维核对映射口径)。 /// /// 本 action 不访问任何数据,但仍解析租户:一是它暴露的是映射口径(源表名 / JSON 路径 / /// 业务键),属运维视角的结构信息;二是 S0ControllerTenantClosureTests 要求 S0 控制器 /// 每个 action 都显式解析租户,豁免需登记进共享白名单并给出评审理由 —— 与其扩白名单, /// 不如让这个运维接口也要求一个已解析的运维上下文(代价为零,口径更一致)。 /// [HttpGet("catalog")] public IActionResult Catalog() { if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!; S0DimCatalog.ValidateAll(); _ = tenantId; // 仅用于确保调用方具备已解析的租户上下文,清单内容本身与租户无关 return Ok(S0DimCatalog.All.Select(d => new { d.Key, d.EntityCode, d.SourceTable, d.SourceSystem, d.StagingTable, d.DimTable, BusinessKey = d.BusinessKeyColumns, SourceBizKey = d.SourceBizKeyColumns, Columns = d.Columns.Select(c => new { c.TargetColumn, Kind = c.Kind.ToString(), c.JsonPath, c.Required }) })); } private async Task RefreshCoreAsync(string? key, CancellationToken cancellationToken) { if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!; try { var result = await _service.RefreshAsync(tenantId, key, "MANUAL", cancellationToken); // 刷新失败不抛 5xx:结果体里已带逐维度 status / error / 对账明细,便于运维直接读 return Ok(result); } catch (InvalidOperationException ex) { return AdoS0ApiErrors.InvalidRequest(ex.Message); } } }