Skip to content

Commit c1dc90b

Browse files
authored
Merge pull request #239 from super-niuma-001/feat/issue-235-import-flow
feat: add app import preview flow
2 parents 414e22c + d937027 commit c1dc90b

9 files changed

Lines changed: 704 additions & 5 deletions

File tree

src/AgileConfig.Server.Apisite/Controllers/AppController.cs

Lines changed: 239 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
using AgileConfig.Server.Event;
1515
using AgileConfig.Server.IService;
1616
using Microsoft.AspNetCore.Authorization;
17+
using Microsoft.AspNetCore.Http;
1718
using Microsoft.AspNetCore.Mvc;
1819
using Newtonsoft.Json;
1920

@@ -382,6 +383,243 @@ public async Task<IActionResult> Export([FromBody] AppExportRequest model)
382383
return File(Encoding.UTF8.GetBytes(json), "application/json", fileName);
383384
}
384385

386+
[TypeFilter(typeof(PermissionCheckAttribute), Arguments = new object[] { Functions.App_Add })]
387+
[HttpPost]
388+
public async Task<IActionResult> PreviewImport(IFormFile file)
389+
{
390+
var importFile = await ReadImportFileAsync(file);
391+
var preview = await BuildImportPreviewAsync(importFile);
392+
return Json(new
393+
{
394+
success = !preview.Errors.Any(),
395+
data = preview,
396+
message = preview.Errors.FirstOrDefault()
397+
});
398+
}
399+
400+
[TypeFilter(typeof(PermissionCheckAttribute), Arguments = new object[] { Functions.App_Add })]
401+
[HttpPost]
402+
public async Task<IActionResult> Import([FromBody] AppImportRequest model)
403+
{
404+
ArgumentNullException.ThrowIfNull(model);
405+
ArgumentNullException.ThrowIfNull(model.File);
406+
407+
var preview = await BuildImportPreviewAsync(model.File);
408+
if (preview.Errors.Any())
409+
return Json(new
410+
{
411+
success = false,
412+
data = preview,
413+
message = string.Join(Environment.NewLine, preview.Errors)
414+
});
415+
416+
var currentUserId = await this.GetCurrentUserId(_userService);
417+
var now = DateTime.Now;
418+
419+
foreach (var previewItem in preview.Apps.OrderBy(x => x.Order))
420+
{
421+
var importItem = model.File.Apps.First(x => string.Equals(x.App?.Id, previewItem.AppId, StringComparison.OrdinalIgnoreCase));
422+
var app = new App
423+
{
424+
Id = importItem.App.Id,
425+
Name = importItem.App.Name,
426+
Group = importItem.App.Group,
427+
Secret = importItem.App.Secret,
428+
Enabled = importItem.App.Enabled,
429+
Type = importItem.App.Inheritanced ? AppType.Inheritance : AppType.PRIVATE,
430+
CreateTime = now,
431+
Creator = currentUserId
432+
};
433+
434+
var inheritanceApps = BuildInheritanceLinks(importItem.App.InheritancedApps, app.Id);
435+
await _appService.AddAsync(app, inheritanceApps);
436+
437+
foreach (var envConfigs in importItem.Envs)
438+
{
439+
foreach (var configVm in envConfigs.Value ?? new List<AppExportConfigVM>())
440+
{
441+
var config = new Config
442+
{
443+
Id = Guid.NewGuid().ToString("N"),
444+
AppId = app.Id,
445+
Env = envConfigs.Key,
446+
Group = configVm.Group,
447+
Key = configVm.Key,
448+
Value = configVm.Value,
449+
Description = configVm.Description,
450+
CreateTime = now,
451+
Status = ConfigStatus.Enabled,
452+
OnlineStatus = OnlineStatus.WaitPublish,
453+
EditStatus = EditStatus.Add
454+
};
455+
await _configService.AddAsync(config, envConfigs.Key);
456+
}
457+
}
458+
}
459+
460+
return Json(new
461+
{
462+
success = true,
463+
data = preview
464+
});
465+
}
466+
467+
private static List<AppInheritanced> BuildInheritanceLinks(List<string> parentIds, string appId)
468+
{
469+
var inheritanceApps = new List<AppInheritanced>();
470+
if (parentIds == null) return inheritanceApps;
471+
472+
var sort = 0;
473+
foreach (var parentId in parentIds.Where(x => !string.IsNullOrWhiteSpace(x)).Distinct(StringComparer.OrdinalIgnoreCase))
474+
inheritanceApps.Add(new AppInheritanced
475+
{
476+
Id = Guid.NewGuid().ToString("N"),
477+
AppId = appId,
478+
InheritancedAppId = parentId,
479+
Sort = sort++
480+
});
481+
482+
return inheritanceApps;
483+
}
484+
485+
private async Task<AppExportFileVM> ReadImportFileAsync(IFormFile file)
486+
{
487+
if (file == null || file.Length == 0) throw new ArgumentException("file");
488+
489+
using var stream = file.OpenReadStream();
490+
using var reader = new System.IO.StreamReader(stream, Encoding.UTF8);
491+
var content = await reader.ReadToEndAsync();
492+
var importFile = JsonConvert.DeserializeObject<AppExportFileVM>(content);
493+
if (importFile == null) throw new ArgumentException("file");
494+
495+
return importFile;
496+
}
497+
498+
private async Task<AppImportPreviewVM> BuildImportPreviewAsync(AppExportFileVM importFile)
499+
{
500+
var preview = new AppImportPreviewVM();
501+
if (importFile?.Apps == null || !importFile.Apps.Any())
502+
{
503+
preview.Errors.Add("Import file does not contain any apps.");
504+
return preview;
505+
}
506+
507+
var appItems = importFile.Apps
508+
.Where(x => x?.App != null)
509+
.ToList();
510+
if (!appItems.Any())
511+
{
512+
preview.Errors.Add("Import file does not contain any valid app entries.");
513+
return preview;
514+
}
515+
516+
var duplicateIds = appItems
517+
.GroupBy(x => x.App.Id ?? string.Empty, StringComparer.OrdinalIgnoreCase)
518+
.Where(x => !string.IsNullOrWhiteSpace(x.Key) && x.Count() > 1)
519+
.Select(x => x.Key)
520+
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
521+
.ToList();
522+
preview.Errors.AddRange(duplicateIds.Select(x => $"Duplicate AppId in import file: {x}."));
523+
524+
var duplicateNames = appItems
525+
.GroupBy(x => x.App.Name ?? string.Empty, StringComparer.OrdinalIgnoreCase)
526+
.Where(x => !string.IsNullOrWhiteSpace(x.Key) && x.Count() > 1)
527+
.Select(x => x.Key)
528+
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
529+
.ToList();
530+
preview.Errors.AddRange(duplicateNames.Select(x => $"Duplicate app name in import file: {x}."));
531+
532+
foreach (var item in appItems)
533+
{
534+
if (string.IsNullOrWhiteSpace(item.App.Id)) preview.Errors.Add("Imported app is missing AppId.");
535+
if (string.IsNullOrWhiteSpace(item.App.Name)) preview.Errors.Add("Imported app is missing Name.");
536+
}
537+
538+
var importedAppIds = new HashSet<string>(appItems.Select(x => x.App.Id).Where(x => !string.IsNullOrWhiteSpace(x)), StringComparer.OrdinalIgnoreCase);
539+
var existingApps = await _appService.GetAllAppsAsync();
540+
541+
foreach (var item in appItems.Where(x => x.App != null && !string.IsNullOrWhiteSpace(x.App.Id)))
542+
{
543+
if (existingApps.Any(x => string.Equals(x.Id, item.App.Id, StringComparison.OrdinalIgnoreCase)))
544+
preview.Errors.Add($"AppId already exists: {item.App.Id}.");
545+
if (!string.IsNullOrWhiteSpace(item.App.Name) && existingApps.Any(x => string.Equals(x.Name, item.App.Name, StringComparison.OrdinalIgnoreCase)))
546+
preview.Errors.Add($"App name already exists: {item.App.Name}.");
547+
}
548+
549+
foreach (var item in appItems)
550+
{
551+
foreach (var parentId in item.App.InheritancedApps?.Where(x => !string.IsNullOrWhiteSpace(x)).Distinct(StringComparer.OrdinalIgnoreCase) ?? new List<string>())
552+
{
553+
if (importedAppIds.Contains(parentId)) continue;
554+
if (existingApps.Any(x => string.Equals(x.Id, parentId, StringComparison.OrdinalIgnoreCase))) continue;
555+
preview.Errors.Add($"App '{item.App.Id}' references missing parent '{parentId}'. Parent must already exist or be included in the import file.");
556+
}
557+
}
558+
559+
var orderLookup = TryTopologicalSort(appItems, importedAppIds, preview.Errors);
560+
if (preview.Errors.Any()) return preview;
561+
562+
preview.Apps = appItems
563+
.OrderBy(x => orderLookup[x.App.Id])
564+
.Select(x => new AppImportPreviewItemVM
565+
{
566+
AppId = x.App.Id,
567+
Name = x.App.Name,
568+
Group = x.App.Group,
569+
Enabled = x.App.Enabled,
570+
Inheritanced = x.App.Inheritanced,
571+
InheritancedApps = x.App.InheritancedApps?.Where(v => !string.IsNullOrWhiteSpace(v)).Distinct(StringComparer.OrdinalIgnoreCase).ToList() ?? new List<string>(),
572+
EnvCount = x.Envs?.Count ?? 0,
573+
ConfigCount = x.Envs?.Sum(env => env.Value?.Count ?? 0) ?? 0,
574+
Order = orderLookup[x.App.Id]
575+
})
576+
.ToList();
577+
578+
return preview;
579+
}
580+
581+
private static Dictionary<string, int> TryTopologicalSort(List<AppExportItemVM> appItems, HashSet<string> importedAppIds, List<string> errors)
582+
{
583+
var dependencyMap = appItems.ToDictionary(
584+
x => x.App.Id,
585+
x => (x.App.InheritancedApps ?? new List<string>())
586+
.Where(parentId => !string.IsNullOrWhiteSpace(parentId) && importedAppIds.Contains(parentId))
587+
.Distinct(StringComparer.OrdinalIgnoreCase)
588+
.ToList(),
589+
StringComparer.OrdinalIgnoreCase);
590+
591+
var inDegree = dependencyMap.ToDictionary(x => x.Key, _ => 0, StringComparer.OrdinalIgnoreCase);
592+
var childMap = dependencyMap.Keys.ToDictionary(x => x, _ => new List<string>(), StringComparer.OrdinalIgnoreCase);
593+
594+
foreach (var entry in dependencyMap)
595+
{
596+
inDegree[entry.Key] = entry.Value.Count;
597+
foreach (var parentId in entry.Value) childMap[parentId].Add(entry.Key);
598+
}
599+
600+
var queue = new Queue<string>(inDegree.Where(x => x.Value == 0).Select(x => x.Key).OrderBy(x => x, StringComparer.OrdinalIgnoreCase));
601+
var ordered = new List<string>();
602+
while (queue.Any())
603+
{
604+
var next = queue.Dequeue();
605+
ordered.Add(next);
606+
607+
foreach (var child in childMap[next].OrderBy(x => x, StringComparer.OrdinalIgnoreCase))
608+
{
609+
inDegree[child]--;
610+
if (inDegree[child] == 0) queue.Enqueue(child);
611+
}
612+
}
613+
614+
if (ordered.Count != dependencyMap.Count)
615+
{
616+
var cyclicApps = inDegree.Where(x => x.Value > 0).Select(x => x.Key).OrderBy(x => x, StringComparer.OrdinalIgnoreCase);
617+
errors.Add($"Cyclic inheritance detected among imported apps: {string.Join(", ", cyclicApps)}.");
618+
}
619+
620+
return ordered.Select((appId, index) => new { appId, index }).ToDictionary(x => x.appId, x => x.index + 1, StringComparer.OrdinalIgnoreCase);
621+
}
622+
385623
/// <summary>
386624
/// Get all applications that can be inherited.
387625
/// </summary>
@@ -462,4 +700,4 @@ public async Task<IActionResult> GetAppGroups()
462700
data = groups.OrderBy(x => x)
463701
});
464702
}
465-
}
703+
}

src/AgileConfig.Server.Apisite/Models/AppExportVM.cs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@ public class AppExportRequest
1010
public List<string> AppIds { get; set; }
1111
}
1212

13+
public class AppImportRequest
14+
{
15+
[JsonProperty("file")]
16+
public AppExportFileVM File { get; set; }
17+
}
18+
1319
public class AppExportFileVM
1420
{
1521
[JsonProperty("schemaVersion")]
@@ -72,3 +78,42 @@ public class AppExportConfigVM
7278
[JsonProperty("description")]
7379
public string Description { get; set; }
7480
}
81+
82+
public class AppImportPreviewVM
83+
{
84+
[JsonProperty("apps")]
85+
public List<AppImportPreviewItemVM> Apps { get; set; } = new();
86+
87+
[JsonProperty("errors")]
88+
public List<string> Errors { get; set; } = new();
89+
}
90+
91+
public class AppImportPreviewItemVM
92+
{
93+
[JsonProperty("appId")]
94+
public string AppId { get; set; }
95+
96+
[JsonProperty("name")]
97+
public string Name { get; set; }
98+
99+
[JsonProperty("group")]
100+
public string Group { get; set; }
101+
102+
[JsonProperty("enabled")]
103+
public bool Enabled { get; set; }
104+
105+
[JsonProperty("inheritanced")]
106+
public bool Inheritanced { get; set; }
107+
108+
[JsonProperty("inheritancedApps")]
109+
public List<string> InheritancedApps { get; set; } = new();
110+
111+
[JsonProperty("envCount")]
112+
public int EnvCount { get; set; }
113+
114+
[JsonProperty("configCount")]
115+
public int ConfigCount { get; set; }
116+
117+
[JsonProperty("order")]
118+
public int Order { get; set; }
119+
}

src/AgileConfig.Server.UI/react-ui-antd/src/locales/en-US/pages.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,22 @@ export default {
132132
'pages.app.table.cols.admin': 'Administrator',
133133
'pages.app.table.cols.action.auth': 'Authorization',
134134
'pages.app.table.group_aggregation': 'Group Aggregation',
135+
'pages.app.import.button': 'Import',
136+
'pages.app.import.title': 'Import Apps',
137+
'pages.app.import.tip': 'Import only creates new apps and config records. Parent apps must already exist or be included in the same file.',
138+
'pages.app.import.select_file': 'Select JSON File',
139+
'pages.app.import.preview.success': 'Import preview generated successfully',
140+
'pages.app.import.preview.failed': 'Import preview failed',
141+
'pages.app.import.preview.order': 'Order',
142+
'pages.app.import.preview.parents': 'Parents',
143+
'pages.app.import.preview.envCount': 'Envs',
144+
'pages.app.import.preview.configCount': 'Configs',
145+
'pages.app.import.validation_failed': 'Import validation failed',
146+
'pages.app.import.no_valid_preview': 'Resolve preview errors before importing',
147+
'pages.app.import.importing': 'Importing apps...',
148+
'pages.app.import.success': 'Apps imported successfully',
149+
'pages.app.import.failed': 'App import failed',
150+
'pages.app.import.empty': 'Upload a file to preview the import',
135151

136152
'pages.app.auth.title': 'User Authorization',
137153
'pages.app.auth.bind_users': 'Bind Users',

src/AgileConfig.Server.UI/react-ui-antd/src/locales/zh-CN/pages.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,22 @@ export default {
129129
'pages.app.table.cols.admin': '管理员',
130130
'pages.app.table.cols.action.auth': '授权',
131131
'pages.app.table.group_aggregation': '分组聚合',
132+
'pages.app.import.button': '导入',
133+
'pages.app.import.title': '导入应用',
134+
'pages.app.import.tip': '导入仅创建新应用和新的配置记录。父应用必须已存在于目标系统中,或同时包含在导入文件内。',
135+
'pages.app.import.select_file': '选择 JSON 文件',
136+
'pages.app.import.preview.success': '导入预览生成成功',
137+
'pages.app.import.preview.failed': '导入预览失败',
138+
'pages.app.import.preview.order': '顺序',
139+
'pages.app.import.preview.parents': '父应用',
140+
'pages.app.import.preview.envCount': '环境数',
141+
'pages.app.import.preview.configCount': '配置数',
142+
'pages.app.import.validation_failed': '导入校验失败',
143+
'pages.app.import.no_valid_preview': '请先解决预览中的错误再导入',
144+
'pages.app.import.importing': '正在导入应用...',
145+
'pages.app.import.success': '应用导入成功',
146+
'pages.app.import.failed': '应用导入失败',
147+
'pages.app.import.empty': '上传文件后可预览导入内容',
132148

133149
'pages.app.auth.title': '用户授权',
134150
'pages.app.auth.bind_users': '绑定用户',

0 commit comments

Comments
 (0)