using Admin.NET.Core; using Admin.NET.Core.Service; using Admin.NET.Plugin.AiDOP.DataPlatform.Inbound; using Admin.NET.Plugin.AiDOP.Entity.DataPlatform; using Yitter.IdGenerator; namespace Admin.NET.Plugin.AiDOP.DataPlatform; /// /// 来源映射维护:归属、库位角色、岗位、事务类型、来源能力、推送授权。 /// 页面写入库位角色和岗位时 role_source 固定为 MANUAL,避免被自动分类冲掉。 /// [ApiDescriptionSettings(Order = 326, Description = "来源映射维护")] [Route("api/DataPlatform")] [NonUnify] public class MdpSourceMappingService : IDynamicApiController, ITransient { private readonly ISqlSugarClient _db; private readonly UserManager _user; private readonly SysCacheService _cache; public MdpSourceMappingService(ISqlSugarClient db, UserManager user, SysCacheService cache) { _db = db; _user = user; _cache = cache; } private long ScopeTenant(long? requested) { if (_user.SuperAdmin || _user.SysAdmin) return requested is > 0 ? requested.Value : _user.TenantId; if (requested is > 0 && requested.Value != _user.TenantId) throw Oops.Oh("不能查看或修改其他租户的对接配置"); return _user.TenantId; } [HttpGet("source-domain-maps")] public Task> ListDomainMaps([FromQuery] long? tenantId) { var tenant = ScopeTenant(tenantId); return _db.Ado.SqlQueryAsync( """ SELECT id, system_code AS systemCode, source_code AS sourceCode, domain, tenant_id AS tenantId, status, remark FROM ado_source_domain_tenant_map WHERE tenant_id=@tenant ORDER BY system_code, domain """, new { tenant }); } [HttpPut("source-domain-maps")] public async Task SaveDomainMap([FromBody] DomainMapInput input) { var tenant = ScopeTenant(input.TenantId); if (string.IsNullOrWhiteSpace(input.SystemCode) || string.IsNullOrWhiteSpace(input.Domain)) throw Oops.Oh("systemCode 与 domain 必填"); var clash = await _db.Ado.SqlQueryAsync( """ SELECT tenant_id FROM ado_source_domain_tenant_map WHERE system_code=@sys AND domain=@dom AND status=1 AND tenant_id<>@tenant LIMIT 1 """, new { sys = input.SystemCode.Trim(), dom = input.Domain.Trim(), tenant }); if (clash.Count > 0) throw Oops.Oh($"该来源范围已归属租户 {clash[0]}。多个租户共享同一范围时按库位划分,不要改这里。"); await _db.Ado.ExecuteCommandAsync( """ INSERT INTO ado_source_domain_tenant_map (system_code, source_code, domain, tenant_id, status, remark) VALUES (@sys, @src, @dom, @tenant, 1, @remark) ON DUPLICATE KEY UPDATE source_code=VALUES(source_code), status=1, remark=VALUES(remark), tenant_id=VALUES(tenant_id) """, new { sys = input.SystemCode.Trim(), src = string.IsNullOrWhiteSpace(input.SourceCode) ? input.SystemCode.Trim() : input.SourceCode.Trim(), dom = input.Domain.Trim(), tenant, remark = input.Remark }); return new { ok = true }; } [HttpGet("location-roles")] public Task> ListLocations([FromQuery] long? tenantId, [FromQuery] string? domain, [FromQuery] bool unknownOnly = false) { var tenant = ScopeTenant(tenantId); return _db.Ado.SqlQueryAsync( """ SELECT id, tenant_id AS tenantId, domain, location, location_role AS locationRole, role_source AS roleSource, remark FROM mdp_location_role WHERE tenant_id=@tenant AND (@dom = '' OR domain=@dom) AND (@unk = 0 OR location_role='UNKNOWN') ORDER BY domain, location LIMIT 500 """, new { tenant, dom = domain?.Trim() ?? "", unk = unknownOnly ? 1 : 0 }); } [HttpPut("location-roles")] public async Task SaveLocations([FromBody] LocationRoleInput input) { var tenant = ScopeTenant(input.TenantId); if (input.Rows == null || input.Rows.Count == 0) throw Oops.Oh("没有要保存的行"); var n = 0; foreach (var row in input.Rows) { if (string.IsNullOrWhiteSpace(row.Location) || string.IsNullOrWhiteSpace(row.LocationRole)) continue; n += await _db.Ado.ExecuteCommandAsync( """ INSERT INTO mdp_location_role (tenant_id, domain, location, location_role, role_source, remark) VALUES (@tenant, @dom, @loc, @role, 'MANUAL', @remark) ON DUPLICATE KEY UPDATE location_role=VALUES(location_role), role_source='MANUAL', remark=VALUES(remark) """, new { tenant, dom = row.Domain?.Trim() ?? "", loc = row.Location.Trim(), role = row.LocationRole.Trim(), remark = row.Remark }); } return new { saved = n }; } [HttpGet("position-maps")] public Task> ListPositions([FromQuery] long? tenantId, [FromQuery] bool unknownOnly = false) { var tenant = ScopeTenant(tenantId); return _db.Ado.SqlQueryAsync( """ SELECT id, tenant_id AS tenantId, source_system AS sourceSystem, domain, src_position_raw AS srcPositionRaw, src_position_field AS srcPositionField, position_code AS positionCode, role_source AS roleSource, remark FROM mdp_employee_position_map WHERE tenant_id=@tenant AND (@unk = 0 OR position_code='UNKNOWN') ORDER BY source_system, src_position_raw LIMIT 500 """, new { tenant, unk = unknownOnly ? 1 : 0 }); } [HttpPut("position-maps")] public async Task SavePositions([FromBody] PositionMapInput input) { var tenant = ScopeTenant(input.TenantId); if (input.Rows == null || input.Rows.Count == 0) throw Oops.Oh("没有要保存的行"); var n = 0; foreach (var row in input.Rows) { if (string.IsNullOrWhiteSpace(row.SourceSystem) || string.IsNullOrWhiteSpace(row.SrcPositionRaw) || string.IsNullOrWhiteSpace(row.PositionCode)) continue; n += await _db.Ado.ExecuteCommandAsync( """ INSERT INTO mdp_employee_position_map (tenant_id, source_system, domain, src_position_raw, src_position_field, position_code, role_source, remark) VALUES (@tenant, @sys, @dom, @raw, @field, @code, 'MANUAL', @remark) ON DUPLICATE KEY UPDATE position_code=VALUES(position_code), role_source='MANUAL', remark=VALUES(remark) """, new { tenant, sys = row.SourceSystem.Trim(), dom = row.Domain?.Trim() ?? "", raw = row.SrcPositionRaw.Trim(), field = string.IsNullOrWhiteSpace(row.SrcPositionField) ? "Position" : row.SrcPositionField.Trim(), code = row.PositionCode.Trim(), remark = row.Remark }); } return new { saved = n }; } [HttpGet("trans-type-maps")] public Task> ListTransTypes([FromQuery] string? systemCode) { return _db.Ado.SqlQueryAsync( """ SELECT id, system_code AS systemCode, src_trans_code AS srcTransCode, qty_sign AS qtySign, location_role AS locationRole, stage_code AS stageCode, biz_doc_type AS bizDocType, is_stage AS isStage FROM mdp_trans_type_map WHERE tenant_id=0 AND (@sys = '' OR system_code=@sys) ORDER BY system_code, src_trans_code LIMIT 500 """, new { sys = systemCode?.Trim() ?? "" }); } [HttpPut("trans-type-maps")] public async Task SaveTransType([FromBody] TransTypeInput input) { if (!_user.SuperAdmin && !_user.SysAdmin) throw Oops.Oh("事务类型全局行只有管理员可以改"); if (string.IsNullOrWhiteSpace(input.SystemCode) || string.IsNullOrWhiteSpace(input.SrcTransCode)) throw Oops.Oh("systemCode 与 srcTransCode 必填"); await _db.Ado.ExecuteCommandAsync( """ INSERT INTO mdp_trans_type_map (tenant_id, system_code, src_trans_code, qty_sign, location_role, stage_code, biz_doc_type, is_stage, excluded_by_location) VALUES (0, @sys, @src, @sgn, @role, @stage, @biz, @isStage, 0) ON DUPLICATE KEY UPDATE stage_code=VALUES(stage_code), biz_doc_type=VALUES(biz_doc_type), is_stage=VALUES(is_stage) """, new { sys = input.SystemCode.Trim(), src = input.SrcTransCode.Trim(), sgn = input.QtySign, role = input.LocationRole?.Trim() ?? "", stage = input.StageCode, biz = input.BizDocType, isStage = input.IsStage }); return new { ok = true }; } [HttpGet("source-capabilities")] public Task> ListCapabilities([FromQuery] string systemCode) { if (string.IsNullOrWhiteSpace(systemCode)) throw Oops.Oh("systemCode 必填"); return _db.Ado.SqlQueryAsync( """ SELECT semantic_code AS semanticCode, supported, reason, evidence FROM mdp_source_capability WHERE source_system=@sys ORDER BY semantic_code """, new { sys = systemCode.Trim() }); } [HttpPut("source-capabilities")] public async Task SaveCapability([FromBody] CapabilityInput input) { if (string.IsNullOrWhiteSpace(input.SystemCode) || string.IsNullOrWhiteSpace(input.SemanticCode)) throw Oops.Oh("systemCode 与 semanticCode 必填"); await _db.Ado.ExecuteCommandAsync( """ INSERT INTO mdp_source_capability (source_system, semantic_code, supported, reason, evidence, verified_at) VALUES (@sys, @code, @sup, @reason, @evidence, NOW()) ON DUPLICATE KEY UPDATE supported=VALUES(supported), reason=VALUES(reason), evidence=VALUES(evidence), verified_at=NOW() """, new { sys = input.SystemCode.Trim(), code = input.SemanticCode.Trim(), sup = input.Supported ? 1 : 0, reason = input.Reason ?? "", evidence = input.Evidence }); return new { ok = true }; } [HttpGet("inbound-grants")] public async Task> ListGrants([FromQuery] long? tenantId, [FromQuery] string? sourceCode) { var tenant = ScopeTenant(tenantId); var rows = await _db.Ado.SqlQueryAsync( """ SELECT id, entity_code AS entityCode, source_code AS sourceCode, rate_limit_per_min AS rateLimitPerMin, status, CONCAT(LEFT(access_key, 4), '…') AS accessKeyMask FROM mdp_inbound_grant WHERE tenant_id=@tenant AND (@src = '' OR source_code=@src) ORDER BY source_code, entity_code """, new { tenant, src = sourceCode?.Trim() ?? "" }); return rows; } [HttpGet("required-columns")] public object RequiredColumns([FromQuery] string stdObject) { var def = MdpStdObjectCatalog.Find(stdObject); if (def == null) throw Oops.Oh($"未知标准对象 {stdObject}"); return new { stdObject = def.Code, targetTable = def.Tables.FirstOrDefault(), columns = NeutralRequiredColumns.For(def.Code).Select(c => new { name = c.Name, semanticCode = c.SemanticCode }) }; } [HttpGet("semantic-codes")] public object SemanticCodes() => Admin.NET.Plugin.AiDOP.SmartOps.KpiSemanticDependency.DistinctSemantics(); [HttpPost("inbound-grants")] public async Task CreateGrant([FromBody] GrantCreateInput input) { var tenant = ScopeTenant(input.TenantId); if (string.IsNullOrWhiteSpace(input.SourceCode) || string.IsNullOrWhiteSpace(input.EntityCode)) throw Oops.Oh("sourceCode 与 entityCode 必填"); var source = await _db.Queryable() .Where(s => s.SourceCode == input.SourceCode.Trim()) .FirstAsync() ?? throw Oops.Oh("来源不存在"); if (!string.Equals(source.SourceType, "API_INBOUND", StringComparison.OrdinalIgnoreCase)) throw Oops.Oh("只有对方推送来源可以发授权"); var requestedKey = input.AccessKey?.Trim() ?? ""; var identity = string.IsNullOrEmpty(requestedKey) ? null : await _db.Queryable().FirstAsync(x => x.AccessKey == requestedKey); var plan = InboundGrantIssuance.Plan( requestedKey, tenant, identity?.BindTenantId ?? 0, identity != null, NewToken, NewToken); var entityCode = input.EntityCode.Trim(); switch (plan.Kind) { case InboundGrantIssuanceKind.RejectUnknownKey: throw Oops.Oh("访问标识不存在。留空将新建一套凭据,或先在开放接口身份中建立该标识"); case InboundGrantIssuanceKind.RejectCrossTenant: throw Oops.Oh("该访问标识属于其他租户,不能用于本租户的推送授权"); } var duplicated = await _db.Queryable() .AnyAsync(x => x.AccessKey == plan.AccessKey && x.EntityCode == entityCode); if (duplicated) throw Oops.Oh("该访问标识已经授权过这个业务对象"); long bindUserId = 0; if (plan.Kind == InboundGrantIssuanceKind.CreateIdentity) { bindUserId = await _db.Queryable() .Where(t => t.Id == tenant) .Select(t => t.UserId) .FirstAsync(); if (bindUserId <= 0) throw Oops.Oh("租户尚未生成租管用户,无法签发推送身份"); } var now = DateTime.Now; try { _db.Ado.BeginTran(); if (plan.Kind == InboundGrantIssuanceKind.CreateIdentity) { await _db.Insertable(new SysOpenAccess { Id = YitIdHelper.NextId(), AccessKey = plan.AccessKey, AccessSecret = plan.AccessSecret, BindTenantId = tenant, BindUserId = bindUserId, CreateTime = now, }).ExecuteCommandAsync(); } await _db.Insertable(new MdpInboundGrant { TenantId = tenant, AccessKey = plan.AccessKey, EntityCode = entityCode, SourceCode = source.SourceCode, RateLimitPerMin = input.RateLimitPerMin <= 0 ? 60 : input.RateLimitPerMin, BatchRowLimit = 500, Status = 1, CreateTime = now, UpdateTime = now }).ExecuteCommandAsync(); _db.Ado.CommitTran(); } catch { _db.Ado.RollbackTran(); throw; } if (plan.Kind == InboundGrantIssuanceKind.CreateIdentity) _cache.Remove(CacheConst.KeyOpenAccess + plan.AccessKey); // 密钥只在新建时回显一次。复用已有标识时调用方本就持有密钥,不再返回。 return plan.Kind == InboundGrantIssuanceKind.CreateIdentity ? new { accessKey = plan.AccessKey, accessSecret = plan.AccessSecret, entityCode, secretShownOnce = true } : (object)new { accessKey = plan.AccessKey, entityCode, secretShownOnce = false }; } private static string NewToken() => Convert.ToHexString(System.Security.Cryptography.RandomNumberGenerator.GetBytes(16)).ToLowerInvariant(); } public sealed class DomainMapInput { public long TenantId { get; set; } public string SystemCode { get; set; } = ""; public string? SourceCode { get; set; } public string Domain { get; set; } = ""; public string? Remark { get; set; } } public sealed class LocationRoleInput { public long TenantId { get; set; } public List Rows { get; set; } = []; } public sealed class LocationRoleRow { public string? Domain { get; set; } public string Location { get; set; } = ""; public string LocationRole { get; set; } = ""; public string? Remark { get; set; } } public sealed class PositionMapInput { public long TenantId { get; set; } public List Rows { get; set; } = []; } public sealed class PositionMapRow { public string SourceSystem { get; set; } = ""; public string? Domain { get; set; } public string SrcPositionRaw { get; set; } = ""; public string? SrcPositionField { get; set; } public string PositionCode { get; set; } = ""; public string? Remark { get; set; } } public sealed class TransTypeInput { public string SystemCode { get; set; } = ""; public string SrcTransCode { get; set; } = ""; public int QtySign { get; set; } public string? LocationRole { get; set; } public string? StageCode { get; set; } public string? BizDocType { get; set; } public int IsStage { get; set; } = 1; } public sealed class CapabilityInput { public string SystemCode { get; set; } = ""; public string SemanticCode { get; set; } = ""; public bool Supported { get; set; } public string? Reason { get; set; } public string? Evidence { get; set; } } public sealed class GrantCreateInput { public long TenantId { get; set; } public string SourceCode { get; set; } = ""; public string EntityCode { get; set; } = ""; public int RateLimitPerMin { get; set; } = 60; /// 留空则新建开放接口身份并回显密钥;传入则复用该身份,且必须属于目标租户。 public string AccessKey { get; set; } }