MdpSyncEntityConfigService.cs 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653
  1. using Admin.NET.Plugin.AiDOP.DataPlatform.Executors;
  2. using Admin.NET.Plugin.AiDOP.Dto.DataPlatform;
  3. using Admin.NET.Plugin.AiDOP.Entity.DataPlatform;
  4. namespace Admin.NET.Plugin.AiDOP.DataPlatform;
  5. /// <summary>
  6. /// 同步任务实体与字段映射配置 API。
  7. /// </summary>
  8. [ApiDescriptionSettings(Order = 324, Description = "数据中台同步实体与字段映射")]
  9. [Route("api/DataPlatform")]
  10. [NonUnify]
  11. public class MdpSyncEntityConfigService : IDynamicApiController, ITransient
  12. {
  13. private static readonly HashSet<string> ValidFieldTypes = new(StringComparer.OrdinalIgnoreCase)
  14. {
  15. "DIRECT", "JSONPATH", "SCRIPT", "CONST", "LOOKUP"
  16. };
  17. private readonly ISqlSugarClient _db;
  18. private readonly UserManager _userManager;
  19. private readonly MdpSourcePullDispatcher _dispatcher;
  20. private readonly MdpPullFieldMappingService _fieldMappings;
  21. public MdpSyncEntityConfigService(
  22. ISqlSugarClient db,
  23. UserManager userManager,
  24. MdpSourcePullDispatcher dispatcher,
  25. MdpPullFieldMappingService fieldMappings)
  26. {
  27. _db = db;
  28. _userManager = userManager;
  29. _dispatcher = dispatcher;
  30. _fieldMappings = fieldMappings;
  31. }
  32. [DisplayName("同步任务实体列表")]
  33. [HttpGet("sync-tasks/{taskCode}/entities")]
  34. public async Task<object> GetEntities(string taskCode, [FromQuery] int? status = null)
  35. {
  36. var tenantId = _userManager.TenantId;
  37. var task = await FindTaskAsync(taskCode, tenantId);
  38. var prefix = ResolveEntityCodePrefix(task.TaskCode);
  39. var jobCode = string.IsNullOrWhiteSpace(task.JobCode) ? task.TaskCode : task.JobCode;
  40. var where = new List<string> { "(e.tenant_id = @TenantId OR e.tenant_id = 0)" };
  41. var pars = new List<SugarParameter> { new("@TenantId", tenantId) };
  42. if (status is 0 or 1)
  43. {
  44. where.Add("e.status = @Status");
  45. pars.Add(new SugarParameter("@Status", status.Value));
  46. }
  47. if (!string.IsNullOrWhiteSpace(prefix))
  48. {
  49. where.Add("(e.entity_code LIKE @EntityPrefix OR e.job_id = @JobCode)");
  50. pars.Add(new SugarParameter("@EntityPrefix", prefix + "%"));
  51. pars.Add(new SugarParameter("@JobCode", jobCode));
  52. }
  53. else
  54. {
  55. where.Add("e.job_id = @JobCode");
  56. pars.Add(new SugarParameter("@JobCode", jobCode));
  57. }
  58. var whereSql = string.Join(" AND ", where);
  59. var list = await _db.Ado.SqlQueryAsync<MdpEntityRow>(
  60. $"""
  61. SELECT e.id AS Id, e.tenant_id AS TenantId, e.source_id AS SourceId,
  62. s.source_code AS SourceCode, s.source_name AS SourceName, s.source_type AS SourceType,
  63. e.entity_code AS EntityCode, e.entity_name AS EntityName, e.entity_type AS EntityType,
  64. e.source_table_name AS SourceTableName, e.transform_source_table AS TransformSourceTable,
  65. e.source_api_path AS SourceApiPath,
  66. e.target_table_name AS TargetTableName, e.sync_mode AS SyncMode, e.incr_column AS IncrColumn,
  67. e.tie_breaker_column AS TieBreakerColumn, e.biz_key_expr AS BizKeyExpr,
  68. e.batch_size AS BatchSize, e.job_id AS JobId, e.status AS Status,
  69. e.last_cursor AS LastCursor, e.last_sync_to AS LastSyncTo, e.remark AS Remark,
  70. IFNULL(m.cnt, 0) AS FieldMappingCount
  71. FROM mdp_entity e
  72. LEFT JOIN mdp_source s ON s.id = e.source_id
  73. LEFT JOIN (
  74. SELECT entity_id, COUNT(1) AS cnt FROM mdp_field_mapping GROUP BY entity_id
  75. ) m ON m.entity_id = e.id
  76. WHERE {whereSql}
  77. ORDER BY e.entity_code
  78. """,
  79. pars);
  80. foreach (var row in list)
  81. ApplyConfigState(row);
  82. return list;
  83. }
  84. [DisplayName("数据库同步实体模板")]
  85. [HttpGet("sync-tasks/{taskCode}/entity-templates")]
  86. public async Task<object> GetEntityTemplates(string taskCode, [FromQuery] string? moduleCode = null)
  87. {
  88. var tenantId = _userManager.TenantId;
  89. await FindTaskAsync(taskCode, tenantId);
  90. var module = (moduleCode ?? "").Trim().ToUpperInvariant();
  91. var whereModule = module.Length == 2 && module[0] == 'S' && char.IsDigit(module[1])
  92. ? " AND e.entity_code LIKE @ModulePrefix"
  93. : "";
  94. var parameters = string.IsNullOrEmpty(whereModule)
  95. ? Array.Empty<SugarParameter>()
  96. : [new SugarParameter("@ModulePrefix", module + "_%")];
  97. var rows = await _db.Ado.SqlQueryAsync<MdpEntityRow>(
  98. $"""
  99. SELECT e.id AS Id, e.tenant_id AS TenantId, e.source_id AS SourceId,
  100. s.source_code AS SourceCode, s.source_name AS SourceName, s.source_type AS SourceType,
  101. e.entity_code AS EntityCode, e.entity_name AS EntityName, e.entity_type AS EntityType,
  102. e.source_table_name AS SourceTableName,
  103. COALESCE(NULLIF(e.transform_source_table,''), e.source_table_name) AS TransformSourceTable,
  104. e.source_api_path AS SourceApiPath, e.target_table_name AS TargetTableName,
  105. e.sync_mode AS SyncMode, e.incr_column AS IncrColumn,
  106. e.tie_breaker_column AS TieBreakerColumn, e.biz_key_expr AS BizKeyExpr,
  107. e.batch_size AS BatchSize, e.job_id AS JobId, e.status AS Status,
  108. e.last_cursor AS LastCursor, e.last_sync_to AS LastSyncTo, e.remark AS Remark,
  109. IFNULL(m.cnt, 0) AS FieldMappingCount
  110. FROM mdp_entity e
  111. LEFT JOIN mdp_source s ON s.id=e.source_id
  112. LEFT JOIN (
  113. SELECT entity_id, COUNT(1) AS cnt FROM mdp_field_mapping GROUP BY entity_id
  114. ) m ON m.entity_id=e.id
  115. WHERE e.tenant_id=0
  116. AND e.source_table_name IS NOT NULL AND e.source_table_name<>''
  117. AND e.target_table_name LIKE 'mdp_stg_%'
  118. {whereModule}
  119. ORDER BY e.entity_code
  120. """,
  121. parameters);
  122. return rows;
  123. }
  124. [DisplayName("新增同步任务实体")]
  125. [HttpPost("sync-tasks/{taskCode}/entities")]
  126. public async Task<object> CreateEntity(string taskCode, [FromBody] MdpEntityUpsertInput input)
  127. {
  128. ValidateEntityUpsert(input);
  129. var tenantId = _userManager.TenantId;
  130. var task = await FindTaskAsync(taskCode, tenantId);
  131. var sourceId = await ResolveSourceIdAsync(input, tenantId);
  132. var entityTenantId = task.TenantId == 0 ? 0L : tenantId;
  133. var jobCode = string.IsNullOrWhiteSpace(task.JobCode) ? task.TaskCode : task.JobCode;
  134. MdpEntity? template = null;
  135. List<MdpFieldMapping> templateMappings = [];
  136. if (input.TemplateEntityId is > 0)
  137. {
  138. template = await _db.Queryable<MdpEntity>()
  139. .Where(x => x.Id == input.TemplateEntityId.Value
  140. && x.TenantId == 0
  141. && x.SourceTableName != null
  142. && x.SourceTableName != ""
  143. && x.TargetTableName != null
  144. && x.TargetTableName.StartsWith("mdp_stg_"))
  145. .FirstAsync()
  146. ?? throw Oops.Oh("内置实体模板不存在或不可复制");
  147. if (!string.Equals(template.EntityCode, input.EntityCode.Trim(), StringComparison.OrdinalIgnoreCase))
  148. throw Oops.Oh("实体编码必须与所选内置模板一致");
  149. templateMappings = await _db.Queryable<MdpFieldMapping>()
  150. .Where(x => x.EntityId == template.Id)
  151. .OrderBy(x => x.SortOrder)
  152. .ToListAsync();
  153. }
  154. var exists = await _db.Queryable<MdpEntity>()
  155. .Where(u => u.TenantId == entityTenantId && u.EntityCode == input.EntityCode.Trim())
  156. .AnyAsync();
  157. if (exists)
  158. throw Oops.Oh("实体编码已存在");
  159. var now = DateTime.Now;
  160. var entity = new MdpEntity
  161. {
  162. TenantId = entityTenantId,
  163. SourceId = sourceId,
  164. EntityCode = input.EntityCode.Trim(),
  165. EntityName = input.EntityName.Trim(),
  166. EntityType = template?.EntityType
  167. ?? (string.IsNullOrWhiteSpace(input.EntityType) ? "TABLE" : input.EntityType.Trim()),
  168. SourceTableName = input.SourceTableName?.Trim(),
  169. TransformSourceTable = !string.IsNullOrWhiteSpace(input.TransformSourceTable)
  170. ? input.TransformSourceTable.Trim()
  171. : template == null
  172. ? input.SourceTableName?.Trim()
  173. : (string.IsNullOrWhiteSpace(template.TransformSourceTable)
  174. ? template.SourceTableName
  175. : template.TransformSourceTable),
  176. SourceApiPath = input.SourceApiPath?.Trim(),
  177. TargetTableName = template?.TargetTableName ?? input.TargetTableName?.Trim(),
  178. SyncMode = NormalizeSyncMode(input.SyncMode),
  179. IncrColumn = input.IncrColumn?.Trim(),
  180. TieBreakerColumn = input.TieBreakerColumn?.Trim(),
  181. BatchSize = input.BatchSize is > 0 and <= 10000 ? input.BatchSize.Value : 5000,
  182. JobId = string.IsNullOrWhiteSpace(input.JobId) ? jobCode : input.JobId.Trim(),
  183. Status = input.Status <= 0 ? 0 : 1,
  184. Remark = input.Remark?.Trim(),
  185. InboundEnabled = await ResolveInboundEnabledAsync(sourceId, input.InboundEnabled, 0, input.BizKeyExpr),
  186. BizKeyExpr = !string.IsNullOrWhiteSpace(input.BizKeyExpr)
  187. ? input.BizKeyExpr.Trim()
  188. : template?.BizKeyExpr,
  189. CreateTime = now,
  190. UpdateTime = now
  191. };
  192. await EnsureRunnableAsync(entity);
  193. long id = 0;
  194. var tran = await _db.Ado.UseTranAsync(async () =>
  195. {
  196. id = await _db.Insertable(entity).ExecuteReturnBigIdentityAsync();
  197. if (templateMappings.Count > 0)
  198. {
  199. var copies = templateMappings.Select(x => new MdpFieldMapping
  200. {
  201. EntityId = id,
  202. SourceField = x.SourceField,
  203. TargetField = x.TargetField,
  204. FieldType = x.FieldType,
  205. TransformScript = x.TransformScript,
  206. ConstValue = x.ConstValue,
  207. LookupTable = x.LookupTable,
  208. IsRequired = x.IsRequired,
  209. DefaultValue = x.DefaultValue,
  210. SortOrder = x.SortOrder,
  211. CreateTime = now
  212. }).ToList();
  213. await _db.Insertable(copies).ExecuteCommandAsync();
  214. }
  215. await BumpTaskConfigVersionAsync(task);
  216. });
  217. if (!tran.IsSuccess)
  218. throw tran.ErrorException ?? Oops.Oh("复制内置实体模板失败");
  219. return new { id };
  220. }
  221. /// <summary>
  222. /// 按实体立即拉取一次(源 → 贴源)。
  223. ///
  224. /// <para><b>为什么需要它</b>:<see cref="Executors.MdpModuleStagingPuller"/> 的实体清单全部由调用方
  225. /// 显式传入,各模块只传自己硬编码的那一份(<c>S1MdpEntityConfig.All</c> 等),配置层新增的
  226. /// 自定义实体没有任何运行时入口,登记完也拉不动。这里补一个受控的通用触发口。</para>
  227. ///
  228. /// <para><b>越权面收敛</b>:不把 entityCode 直接透传给 dispatcher,而是先按「归属本任务 +
  229. /// 当前租户可见 + 已启用」三条件校验,避免借该端点拉取任意实体。</para>
  230. /// </summary>
  231. [DisplayName("按实体立即拉取一次")]
  232. [HttpPost("sync-tasks/{taskCode}/entities/{entityCode}/pull")]
  233. public async Task<object> PullEntityNow(
  234. string taskCode,
  235. string entityCode,
  236. [FromQuery] bool fullRefresh = false,
  237. CancellationToken cancellationToken = default)
  238. {
  239. var tenantId = _userManager.TenantId;
  240. // tenantId<=0 时贴源写入的租户解析会回落到 null,整行被静默跳过(只打 Warning),
  241. // 对外表现为「拉取成功但 0 行」。提前挡住,不给静默失败留口子。
  242. if (tenantId <= 0)
  243. throw Oops.Oh("无法解析当前租户,请在具体租户下执行拉取");
  244. var task = await FindTaskAsync(taskCode, tenantId);
  245. var jobCode = string.IsNullOrWhiteSpace(task.JobCode) ? task.TaskCode : task.JobCode;
  246. var code = entityCode.Trim();
  247. var target = await _db.Queryable<MdpEntity>()
  248. .Where(u => u.EntityCode == code
  249. && u.Status == 1
  250. && u.JobId == jobCode
  251. && (u.TenantId == tenantId || u.TenantId == 0))
  252. .OrderBy(u => u.TenantId, OrderByType.Desc)
  253. .FirstAsync(cancellationToken)
  254. ?? throw Oops.Oh($"实体 {code} 不存在、未启用,或不归属任务 {taskCode}");
  255. // sync_batch_id 是 varchar(64):固定段 22 字符 + 实体编码最多截 42 字符,
  256. // 截尾只落在实体编码上,不动时间戳,避免批次号互相撞。
  257. var codePart = code.Length > 42 ? code[..42] : code;
  258. var ctx = new MdpPullContext
  259. {
  260. TenantId = tenantId,
  261. FullRefresh = fullRefresh,
  262. BatchId = $"MANUAL_{DateTime.Now:yyyyMMddHHmmss}_{codePart}",
  263. TaskCode = task.TaskCode
  264. };
  265. try
  266. {
  267. var result = await _dispatcher.PullAllByEntityCodeAsync(code, ctx, cancellationToken);
  268. return new
  269. {
  270. entityCode = code,
  271. targetTable = target.TargetTableName,
  272. batchId = ctx.BatchId,
  273. requestedFullRefresh = fullRefresh,
  274. // 调度窗口可能把 FULL 落成 ctx.FullRefresh=true,回传实际生效值,不做黑盒
  275. effectiveWindowType = ctx.SyncWindowType,
  276. effectiveFullRefresh = ctx.FullRefresh,
  277. rowsPulled = result.RowsPulled,
  278. rowsWritten = result.RowsWritten,
  279. newCursor = result.NewCursor,
  280. message = result.Message
  281. };
  282. }
  283. catch (InvalidOperationException ex)
  284. {
  285. // 贴源契约缺列、源表标识符非法、源不可达等都走这里;转业务异常把原因如实带回前端
  286. throw Oops.Oh(ex.Message);
  287. }
  288. }
  289. [DisplayName("更新同步实体")]
  290. [HttpPut("entities/{id:long}")]
  291. public async Task<object> UpdateEntity(long id, [FromBody] MdpEntityUpsertInput input)
  292. {
  293. ValidateEntityUpsert(input);
  294. var tenantId = _userManager.TenantId;
  295. var entity = await FindEditableEntityAsync(id, tenantId);
  296. entity.EntityName = input.EntityName.Trim();
  297. entity.EntityType = string.IsNullOrWhiteSpace(input.EntityType) ? "TABLE" : input.EntityType.Trim();
  298. entity.SourceId = await ResolveSourceIdAsync(input, tenantId, entity.SourceId);
  299. entity.SourceTableName = input.SourceTableName?.Trim();
  300. entity.TransformSourceTable = string.IsNullOrWhiteSpace(input.TransformSourceTable)
  301. ? (string.IsNullOrWhiteSpace(entity.TransformSourceTable)
  302. ? input.SourceTableName?.Trim()
  303. : entity.TransformSourceTable)
  304. : input.TransformSourceTable.Trim();
  305. entity.SourceApiPath = input.SourceApiPath?.Trim();
  306. entity.TargetTableName = input.TargetTableName?.Trim();
  307. entity.SyncMode = string.IsNullOrWhiteSpace(input.SyncMode) ? entity.SyncMode : NormalizeSyncMode(input.SyncMode);
  308. if (input.IncrColumn != null)
  309. entity.IncrColumn = string.IsNullOrWhiteSpace(input.IncrColumn) ? null : input.IncrColumn.Trim();
  310. if (input.TieBreakerColumn != null)
  311. entity.TieBreakerColumn = string.IsNullOrWhiteSpace(input.TieBreakerColumn) ? null : input.TieBreakerColumn.Trim();
  312. if (input.BatchSize is > 0 and <= 10000)
  313. entity.BatchSize = input.BatchSize.Value;
  314. if (!string.IsNullOrWhiteSpace(input.JobId))
  315. entity.JobId = input.JobId.Trim();
  316. entity.Status = input.Status <= 0 ? 0 : 1;
  317. entity.Remark = input.Remark?.Trim();
  318. entity.InboundEnabled = await ResolveInboundEnabledAsync(entity.SourceId, input.InboundEnabled, entity.InboundEnabled, input.BizKeyExpr ?? entity.BizKeyExpr);
  319. if (input.BizKeyExpr != null)
  320. entity.BizKeyExpr = string.IsNullOrWhiteSpace(input.BizKeyExpr) ? null : input.BizKeyExpr.Trim();
  321. entity.UpdateTime = DateTime.Now;
  322. if (!IsBuiltInEntity(entity))
  323. entity.EntityCode = input.EntityCode.Trim();
  324. await EnsureRunnableAsync(entity);
  325. await _db.Updateable(entity).ExecuteCommandAsync();
  326. return new { id = entity.Id };
  327. }
  328. [DisplayName("删除同步实体")]
  329. [HttpDelete("entities/{id:long}")]
  330. public async Task<object> DeleteEntity(long id)
  331. {
  332. var tenantId = _userManager.TenantId;
  333. var entity = await FindEditableEntityAsync(id, tenantId);
  334. if (IsBuiltInEntity(entity) && entity.TenantId == 0)
  335. throw Oops.Oh("内置 MDP 实体不允许删除");
  336. entity.Status = 0;
  337. entity.UpdateTime = DateTime.Now;
  338. await _db.Updateable(entity).UpdateColumns(u => new { u.Status, u.UpdateTime }).ExecuteCommandAsync();
  339. return new { id = entity.Id };
  340. }
  341. [DisplayName("实体字段映射列表")]
  342. [HttpGet("entities/{entityId:long}/field-mappings")]
  343. public async Task<object> GetFieldMappings(long entityId)
  344. {
  345. var tenantId = _userManager.TenantId;
  346. await FindEditableEntityAsync(entityId, tenantId);
  347. var list = await _db.Queryable<MdpFieldMapping>()
  348. .Where(u => u.EntityId == entityId)
  349. .OrderBy(u => u.SortOrder)
  350. .OrderBy(u => u.TargetField)
  351. .ToListAsync();
  352. return list.Select(MapFieldMappingRow).ToList();
  353. }
  354. [DisplayName("新增实体字段映射")]
  355. [HttpPost("entities/{entityId:long}/field-mappings")]
  356. public async Task<object> CreateFieldMapping(long entityId, [FromBody] MdpFieldMappingUpsertInput input)
  357. {
  358. ValidateFieldMappingUpsert(input);
  359. var tenantId = _userManager.TenantId;
  360. await FindEditableEntityAsync(entityId, tenantId);
  361. await RejectUnsupportedDbMappingAsync(entityId, input.FieldType);
  362. var exists = await _db.Queryable<MdpFieldMapping>()
  363. .Where(u => u.EntityId == entityId && u.TargetField == input.TargetField.Trim())
  364. .AnyAsync();
  365. if (exists)
  366. throw Oops.Oh("目标字段映射已存在");
  367. var entity = MapFieldMappingInsert(entityId, input);
  368. var id = await _db.Insertable(entity).ExecuteReturnBigIdentityAsync();
  369. _fieldMappings.Invalidate(entityId);
  370. return new { id };
  371. }
  372. [DisplayName("更新字段映射")]
  373. [HttpPut("field-mappings/{id:long}")]
  374. public async Task<object> UpdateFieldMapping(long id, [FromBody] MdpFieldMappingUpsertInput input)
  375. {
  376. ValidateFieldMappingUpsert(input);
  377. var tenantId = _userManager.TenantId;
  378. var mapping = await _db.Queryable<MdpFieldMapping>().Where(u => u.Id == id).FirstAsync();
  379. if (mapping == null)
  380. throw Oops.Oh("字段映射不存在");
  381. await FindEditableEntityAsync(mapping.EntityId, tenantId);
  382. await RejectUnsupportedDbMappingAsync(mapping.EntityId, input.FieldType);
  383. var duplicate = await _db.Queryable<MdpFieldMapping>()
  384. .Where(u => u.EntityId == mapping.EntityId && u.TargetField == input.TargetField.Trim() && u.Id != id)
  385. .AnyAsync();
  386. if (duplicate)
  387. throw Oops.Oh("目标字段映射已存在");
  388. mapping.SourceField = input.SourceField.Trim();
  389. mapping.TargetField = input.TargetField.Trim();
  390. mapping.FieldType = NormalizeFieldType(input.FieldType);
  391. mapping.TransformScript = input.TransformScript?.Trim();
  392. mapping.ConstValue = input.ConstValue?.Trim();
  393. mapping.LookupTable = input.LookupTable?.Trim();
  394. mapping.IsRequired = input.IsRequired ? 1 : 0;
  395. mapping.DefaultValue = input.DefaultValue?.Trim();
  396. mapping.SortOrder = input.SortOrder;
  397. await _db.Updateable(mapping).ExecuteCommandAsync();
  398. _fieldMappings.Invalidate(mapping.EntityId);
  399. return new { id = mapping.Id };
  400. }
  401. [DisplayName("删除字段映射")]
  402. [HttpDelete("field-mappings/{id:long}")]
  403. public async Task<object> DeleteFieldMapping(long id)
  404. {
  405. var tenantId = _userManager.TenantId;
  406. var mapping = await _db.Queryable<MdpFieldMapping>().Where(u => u.Id == id).FirstAsync();
  407. if (mapping == null)
  408. throw Oops.Oh("字段映射不存在");
  409. await FindEditableEntityAsync(mapping.EntityId, tenantId);
  410. await _db.Deleteable<MdpFieldMapping>().Where(u => u.Id == id).ExecuteCommandAsync();
  411. _fieldMappings.Invalidate(mapping.EntityId);
  412. return new { id };
  413. }
  414. private async Task<MdpSyncTask> FindTaskAsync(string taskCode, long tenantId)
  415. {
  416. if (string.IsNullOrWhiteSpace(taskCode))
  417. throw Oops.Oh("任务编码不能为空");
  418. var task = await _db.Queryable<MdpSyncTask>()
  419. .Where(u => u.TaskCode == taskCode && (u.TenantId == tenantId || u.TenantId == 0) && u.Status == 1)
  420. .OrderBy(u => u.TenantId, OrderByType.Desc)
  421. .FirstAsync();
  422. if (task == null)
  423. throw Oops.Oh("同步任务不存在");
  424. return task;
  425. }
  426. private async Task<MdpEntity> FindEditableEntityAsync(long id, long tenantId)
  427. {
  428. var entity = await _db.Queryable<MdpEntity>()
  429. .Where(u => u.Id == id && (u.TenantId == tenantId || u.TenantId == 0))
  430. .FirstAsync();
  431. if (entity == null)
  432. throw Oops.Oh("同步实体不存在");
  433. return entity;
  434. }
  435. private async Task<long> ResolveSourceIdAsync(MdpEntityUpsertInput input, long tenantId, long? fallbackSourceId = null)
  436. {
  437. if (!string.IsNullOrWhiteSpace(input.SourceCode))
  438. {
  439. var sourceId = await _db.Ado.GetLongAsync(
  440. """
  441. SELECT id FROM mdp_source
  442. WHERE source_code = @SourceCode AND (tenant_id = @TenantId OR tenant_id = 0)
  443. ORDER BY tenant_id DESC
  444. LIMIT 1
  445. """,
  446. new List<SugarParameter>
  447. {
  448. new("@SourceCode", input.SourceCode.Trim()),
  449. new("@TenantId", tenantId)
  450. });
  451. if (sourceId > 0)
  452. return sourceId;
  453. throw Oops.Oh($"数据源 {input.SourceCode} 不存在");
  454. }
  455. if (input.SourceId > 0)
  456. return input.SourceId;
  457. if (fallbackSourceId is > 0)
  458. return fallbackSourceId.Value;
  459. throw Oops.Oh("请指定 sourceId 或 sourceCode");
  460. }
  461. private async Task BumpTaskConfigVersionAsync(MdpSyncTask task)
  462. {
  463. task.ConfigVersion += 1;
  464. task.UpdateTime = DateTime.Now;
  465. await _db.Updateable(task).UpdateColumns(u => new { u.ConfigVersion, u.UpdateTime }).ExecuteCommandAsync();
  466. }
  467. private static string? ResolveEntityCodePrefix(string taskCode)
  468. {
  469. if (string.IsNullOrWhiteSpace(taskCode))
  470. return null;
  471. var first = taskCode.Split('_', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault();
  472. if (string.IsNullOrWhiteSpace(first) || first.Length < 2 || first[0] != 'S' || !char.IsDigit(first[1]))
  473. return null;
  474. return first + "_";
  475. }
  476. private static bool IsBuiltInEntity(MdpEntity entity) =>
  477. entity.EntityCode.StartsWith("S1_", StringComparison.OrdinalIgnoreCase)
  478. || entity.EntityCode.StartsWith("S2_", StringComparison.OrdinalIgnoreCase)
  479. || entity.EntityCode.StartsWith("S3_", StringComparison.OrdinalIgnoreCase)
  480. || entity.EntityCode.StartsWith("S4_", StringComparison.OrdinalIgnoreCase);
  481. private async Task<int> ResolveInboundEnabledAsync(long sourceId, int? requested, int existing, string? bizKeyExpr)
  482. {
  483. var sourceType = await _db.Queryable<MdpSource>()
  484. .Where(s => s.Id == sourceId)
  485. .Select(s => s.SourceType)
  486. .FirstAsync();
  487. if (string.Equals(sourceType, "DB", StringComparison.OrdinalIgnoreCase))
  488. return existing;
  489. if (requested == null)
  490. return existing;
  491. if (requested != 1)
  492. return 0;
  493. if (!string.Equals(sourceType, "API_INBOUND", StringComparison.OrdinalIgnoreCase))
  494. throw Oops.Oh("只有对方推送来源可以开通入站");
  495. if (string.IsNullOrWhiteSpace(bizKeyExpr))
  496. throw Oops.Oh("推送实体必须填写业务键");
  497. return 1;
  498. }
  499. private async Task EnsureRunnableAsync(MdpEntity entity)
  500. {
  501. if (entity.Status != 1)
  502. return;
  503. var sourceType = await _db.Queryable<MdpSource>()
  504. .Where(s => s.Id == entity.SourceId)
  505. .Select(s => s.SourceType)
  506. .FirstAsync();
  507. var issues = MdpDbSyncRules.ValidateDbEntity(entity, sourceType);
  508. if (issues.Count > 0)
  509. throw Oops.Oh(string.Join(";", issues.Select(x => x.Message)));
  510. }
  511. private static void ApplyConfigState(MdpEntityRow row)
  512. {
  513. var probe = new MdpEntity
  514. {
  515. SourceTableName = row.SourceTableName,
  516. TargetTableName = row.TargetTableName,
  517. SyncMode = row.SyncMode,
  518. BizKeyExpr = row.BizKeyExpr,
  519. BatchSize = row.BatchSize,
  520. TieBreakerColumn = row.TieBreakerColumn,
  521. IncrColumn = row.IncrColumn
  522. };
  523. var issues = MdpDbSyncRules.ValidateDbEntity(probe, row.SourceType);
  524. row.ConfigErrors = issues.Select(x => x.Message).ToList();
  525. row.ConfigState = row.Status != 1 ? "DISABLED" : issues.Count == 0 ? "COMPLETE" : "INCOMPLETE";
  526. }
  527. private static string NormalizeSyncMode(string? syncMode)
  528. {
  529. var mode = string.IsNullOrWhiteSpace(syncMode) ? "INCR" : syncMode.Trim().ToUpperInvariant();
  530. return mode is "FULL" or "INCR" or "ROLLING" ? mode : mode;
  531. }
  532. private static void ValidateEntityUpsert(MdpEntityUpsertInput input)
  533. {
  534. if (string.IsNullOrWhiteSpace(input.EntityCode))
  535. throw Oops.Oh("实体编码不能为空");
  536. if (string.IsNullOrWhiteSpace(input.EntityName))
  537. throw Oops.Oh("实体名称不能为空");
  538. }
  539. private async Task RejectUnsupportedDbMappingAsync(long entityId, string? fieldType)
  540. {
  541. var normalized = NormalizeFieldType(fieldType);
  542. if (normalized is not ("SCRIPT" or "LOOKUP"))
  543. return;
  544. var sourceType = await _db.Ado.GetStringAsync(
  545. """
  546. SELECT s.source_type FROM mdp_entity e
  547. INNER JOIN mdp_source s ON s.id = e.source_id
  548. WHERE e.id = @id
  549. """,
  550. new SugarParameter("@id", entityId));
  551. if (string.Equals(sourceType, "DB", StringComparison.OrdinalIgnoreCase))
  552. throw Oops.Oh("数据库同步不支持 SCRIPT 或 LOOKUP 映射");
  553. }
  554. private static void ValidateFieldMappingUpsert(MdpFieldMappingUpsertInput input)
  555. {
  556. if (string.IsNullOrWhiteSpace(input.SourceField))
  557. throw Oops.Oh("源字段不能为空");
  558. if (string.IsNullOrWhiteSpace(input.TargetField))
  559. throw Oops.Oh("目标字段不能为空");
  560. }
  561. private static string NormalizeFieldType(string? fieldType)
  562. {
  563. var normalized = string.IsNullOrWhiteSpace(fieldType) ? "DIRECT" : fieldType.Trim().ToUpperInvariant();
  564. return ValidFieldTypes.Contains(normalized) ? normalized : "DIRECT";
  565. }
  566. private static MdpFieldMapping MapFieldMappingInsert(long entityId, MdpFieldMappingUpsertInput input) =>
  567. new()
  568. {
  569. EntityId = entityId,
  570. SourceField = input.SourceField.Trim(),
  571. TargetField = input.TargetField.Trim(),
  572. FieldType = NormalizeFieldType(input.FieldType),
  573. TransformScript = input.TransformScript?.Trim(),
  574. ConstValue = input.ConstValue?.Trim(),
  575. LookupTable = input.LookupTable?.Trim(),
  576. IsRequired = input.IsRequired ? 1 : 0,
  577. DefaultValue = input.DefaultValue?.Trim(),
  578. SortOrder = input.SortOrder,
  579. CreateTime = DateTime.Now
  580. };
  581. private static MdpFieldMappingRow MapFieldMappingRow(MdpFieldMapping row) =>
  582. new()
  583. {
  584. Id = row.Id,
  585. EntityId = row.EntityId,
  586. SourceField = row.SourceField,
  587. TargetField = row.TargetField,
  588. FieldType = row.FieldType,
  589. TransformScript = row.TransformScript,
  590. ConstValue = row.ConstValue,
  591. LookupTable = row.LookupTable,
  592. IsRequired = row.IsRequired != 0,
  593. DefaultValue = row.DefaultValue,
  594. SortOrder = row.SortOrder
  595. };
  596. }