Skip to content

Commit 59e98cc

Browse files
authored
Merge pull request #237 from super-niuma-001/feature/app-batch-export-issue-235
feat: support batch app export
2 parents 895fe68 + 3d1a720 commit 59e98cc

5 files changed

Lines changed: 257 additions & 3 deletions

File tree

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

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using System;
22
using System.Collections.Generic;
33
using System.Linq;
4+
using System.Text;
45
using System.Threading.Tasks;
56
using AgileConfig.Server.Apisite.Filters;
67
using AgileConfig.Server.Apisite.Models;
@@ -14,6 +15,7 @@
1415
using AgileConfig.Server.IService;
1516
using Microsoft.AspNetCore.Authorization;
1617
using Microsoft.AspNetCore.Mvc;
18+
using Newtonsoft.Json;
1719

1820
namespace AgileConfig.Server.Apisite.Controllers;
1921

@@ -22,14 +24,20 @@ namespace AgileConfig.Server.Apisite.Controllers;
2224
public class AppController : Controller
2325
{
2426
private readonly IAppService _appService;
27+
private readonly IConfigService _configService;
28+
private readonly ISettingService _settingService;
2529
private readonly ITinyEventBus _tinyEventBus;
2630
private readonly IUserService _userService;
2731

2832
public AppController(IAppService appService,
2933
IUserService userService,
34+
IConfigService configService,
35+
ISettingService settingService,
3036
ITinyEventBus tinyEventBus)
3137
{
3238
_userService = userService;
39+
_configService = configService;
40+
_settingService = settingService;
3341
_tinyEventBus = tinyEventBus;
3442
_appService = appService;
3543
}
@@ -107,6 +115,26 @@ private async Task AppendInheritancedInfo(List<AppListVM> list)
107115
}
108116
}
109117

118+
private async Task<bool> IsCurrentUserAdmin(string currentUserId)
119+
{
120+
if (string.IsNullOrWhiteSpace(currentUserId)) return false;
121+
122+
var roles = await _userService.GetUserRolesAsync(currentUserId);
123+
return roles.Any(r => r.Id == SystemRoleConstants.AdminId || r.Id == SystemRoleConstants.SuperAdminId);
124+
}
125+
126+
private async Task<App> GetAuthorizedAppAsync(string appId, string currentUserId, bool isAdmin)
127+
{
128+
var app = await _appService.GetAsync(appId);
129+
if (app == null) return null;
130+
131+
if (isAdmin) return app;
132+
133+
var searchResult = await _appService.SearchAsync(appId, null, null, nameof(App.Id), "ascend", 1, 1,
134+
currentUserId, false);
135+
return searchResult.Apps.FirstOrDefault(x => x.Id == appId);
136+
}
137+
110138
[TypeFilter(typeof(PermissionCheckAttribute), Arguments = new object[] { Functions.App_Add })]
111139
[HttpPost]
112140
public async Task<IActionResult> Add([FromBody] AppVM model)
@@ -273,6 +301,87 @@ public async Task<IActionResult> Delete(string id)
273301
});
274302
}
275303

304+
[TypeFilter(typeof(PermissionCheckAttribute), Arguments = new object[] { Functions.App_Read })]
305+
[HttpPost]
306+
public async Task<IActionResult> Export([FromBody] AppExportRequest model)
307+
{
308+
ArgumentNullException.ThrowIfNull(model);
309+
310+
var appIds = model.AppIds?
311+
.Where(x => !string.IsNullOrWhiteSpace(x))
312+
.Distinct(StringComparer.OrdinalIgnoreCase)
313+
.ToList() ?? new List<string>();
314+
if (!appIds.Any()) throw new ArgumentException("appIds");
315+
316+
var currentUserId = await this.GetCurrentUserId(_userService);
317+
var isAdmin = await IsCurrentUserAdmin(currentUserId);
318+
319+
var apps = new List<App>();
320+
foreach (var appId in appIds)
321+
{
322+
var app = await GetAuthorizedAppAsync(appId, currentUserId, isAdmin);
323+
if (app == null)
324+
{
325+
Response.StatusCode = 403;
326+
return new ContentResult();
327+
}
328+
329+
apps.Add(app);
330+
}
331+
332+
var envs = (await _settingService.GetEnvironmentList())
333+
.Where(x => !string.IsNullOrWhiteSpace(x))
334+
.Distinct(StringComparer.OrdinalIgnoreCase)
335+
.OrderBy(x => x, StringComparer.Ordinal)
336+
.ToList();
337+
338+
var exportFile = new AppExportFileVM
339+
{
340+
ExportedAt = DateTime.UtcNow
341+
};
342+
343+
foreach (var app in apps)
344+
{
345+
var inheritancedApps = await _appService.GetInheritancedAppsAsync(app.Id);
346+
var exportItem = new AppExportItemVM
347+
{
348+
App = new AppExportAppVM
349+
{
350+
Id = app.Id,
351+
Name = app.Name,
352+
Group = app.Group,
353+
Secret = app.Secret,
354+
Enabled = app.Enabled,
355+
Type = (int)app.Type,
356+
Inheritanced = app.Type == AppType.Inheritance,
357+
InheritancedApps = inheritancedApps.Select(x => x.Id).ToList()
358+
}
359+
};
360+
361+
foreach (var env in envs)
362+
{
363+
var configs = await _configService.GetByAppIdAsync(app.Id, env);
364+
exportItem.Envs[env] = configs
365+
.OrderBy(x => x.Group ?? string.Empty, StringComparer.Ordinal)
366+
.ThenBy(x => x.Key ?? string.Empty, StringComparer.Ordinal)
367+
.Select(x => new AppExportConfigVM
368+
{
369+
Group = x.Group,
370+
Key = x.Key,
371+
Value = x.Value,
372+
Description = x.Description
373+
})
374+
.ToList();
375+
}
376+
377+
exportFile.Apps.Add(exportItem);
378+
}
379+
380+
var json = JsonConvert.SerializeObject(exportFile, Formatting.Indented);
381+
var fileName = $"agileconfig-export-{DateTime.UtcNow:yyyyMMddHHmmss}.json";
382+
return File(Encoding.UTF8.GetBytes(json), "application/json", fileName);
383+
}
384+
276385
/// <summary>
277386
/// Get all applications that can be inherited.
278387
/// </summary>
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using Newtonsoft.Json;
4+
5+
namespace AgileConfig.Server.Apisite.Models;
6+
7+
public class AppExportRequest
8+
{
9+
[JsonProperty("appIds")]
10+
public List<string> AppIds { get; set; }
11+
}
12+
13+
public class AppExportFileVM
14+
{
15+
[JsonProperty("schemaVersion")]
16+
public int SchemaVersion { get; set; } = 1;
17+
18+
[JsonProperty("exportedAt")]
19+
public DateTime ExportedAt { get; set; }
20+
21+
[JsonProperty("apps")]
22+
public List<AppExportItemVM> Apps { get; set; } = new();
23+
}
24+
25+
public class AppExportItemVM
26+
{
27+
[JsonProperty("app")]
28+
public AppExportAppVM App { get; set; }
29+
30+
[JsonProperty("envs")]
31+
public Dictionary<string, List<AppExportConfigVM>> Envs { get; set; } = new();
32+
}
33+
34+
public class AppExportAppVM
35+
{
36+
[JsonProperty("id")]
37+
public string Id { get; set; }
38+
39+
[JsonProperty("name")]
40+
public string Name { get; set; }
41+
42+
[JsonProperty("group")]
43+
public string Group { get; set; }
44+
45+
[JsonProperty("secret")]
46+
public string Secret { get; set; }
47+
48+
[JsonProperty("enabled")]
49+
public bool Enabled { get; set; }
50+
51+
[JsonProperty("type")]
52+
public int Type { get; set; }
53+
54+
[JsonProperty("inheritanced")]
55+
public bool Inheritanced { get; set; }
56+
57+
[JsonProperty("inheritancedApps")]
58+
public List<string> InheritancedApps { get; set; } = new();
59+
}
60+
61+
public class AppExportConfigVM
62+
{
63+
[JsonProperty("group")]
64+
public string Group { get; set; }
65+
66+
[JsonProperty("key")]
67+
public string Key { get; set; }
68+
69+
[JsonProperty("value")]
70+
public string Value { get; set; }
71+
72+
[JsonProperty("description")]
73+
public string Description { get; set; }
74+
}

src/AgileConfig.Server.UI/react-ui-antd/src/pages/Apps/index.tsx

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { ExclamationCircleOutlined, PlusOutlined } from '@ant-design/icons';
1+
import { DownloadOutlined, ExclamationCircleOutlined, PlusOutlined } from '@ant-design/icons';
22
import {
33
ModalForm,
44
ProFormDependency,
@@ -20,7 +20,7 @@ import {
2020
Switch,
2121
Tag,
2222
} from 'antd';
23-
import React, { useState, useRef, useEffect } from 'react';
23+
import React, { Key, useState, useRef, useEffect } from 'react';
2424
import { getIntl, getLocale, Link, useIntl } from 'umi';
2525
import UpdateForm from './comps/updateForm';
2626
import { AppListItem, AppListParams, AppListResult, UserAppAuth } from './data';
@@ -33,6 +33,7 @@ import {
3333
enableOrdisableApp,
3434
saveAppAuth,
3535
getAppGroups,
36+
exportApps,
3637
} from './service';
3738
import UserAuth from './comps/userAuth';
3839
import AuthorizedEle from '@/components/Authorized/AuthorizedElement';
@@ -182,6 +183,38 @@ const handleUserAppAuth = async (model: UserAppAuth) => {
182183
}
183184
};
184185

186+
const buildExportFileName = () => {
187+
const date = new Date();
188+
const pad = (value: number) => value.toString().padStart(2, '0');
189+
return `agileconfig-export-${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(
190+
date.getDate(),
191+
)}${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}.json`;
192+
};
193+
194+
const handleExportApps = async (apps: AppListItem[]) => {
195+
const intl = getIntl(getLocale());
196+
const hide = message.loading('导出中...');
197+
try {
198+
const file = await exportApps(apps.map((item) => item.id));
199+
hide();
200+
const fileURL = URL.createObjectURL(file);
201+
const a = document.createElement('a');
202+
a.href = fileURL;
203+
a.target = '_blank';
204+
a.download = buildExportFileName();
205+
document.body.appendChild(a);
206+
a.click();
207+
URL.revokeObjectURL(a.href);
208+
document.body.removeChild(a);
209+
message.success('导出成功');
210+
return true;
211+
} catch (error) {
212+
hide();
213+
message.error(intl.formatMessage({ id: 'save_fail' }));
214+
return false;
215+
}
216+
};
217+
185218
const appList: React.FC = (props) => {
186219
const actionRef = useRef<ActionType>();
187220
const addFormRef = useRef<FormInstance>();
@@ -193,6 +226,8 @@ const appList: React.FC = (props) => {
193226
const [userAuthModalVisible, setUserAuthModalVisible] = useState<boolean>(false);
194227
const [currentRow, setCurrentRow] = useState<AppListItem>();
195228
const [dataSource, setDataSource] = useState<AppListResult>();
229+
const [selectedRowKeysState, setSelectedRowKeys] = useState<Key[]>([]);
230+
const [selectedRowsState, setSelectedRows] = useState<AppListItem[]>([]);
196231
const [appGroups, setAppGroups] = useState<{ label: string; value: string }[]>([]);
197232
const [newAppGroupName, setNewAppGroupName] = useState<string>('');
198233
const [appGroupsEnums, setAppGroupsEnums] = useState<{}>({});
@@ -440,6 +475,13 @@ const appList: React.FC = (props) => {
440475
<ProTable
441476
actionRef={actionRef}
442477
options={false}
478+
rowSelection={{
479+
selectedRowKeys: selectedRowKeysState,
480+
onChange: (selectedRowKeys, selectedRows) => {
481+
setSelectedRowKeys(selectedRowKeys);
482+
setSelectedRows(selectedRows as AppListItem[]);
483+
},
484+
}}
443485
search={{
444486
labelWidth: 'auto',
445487
}}
@@ -486,6 +528,22 @@ const appList: React.FC = (props) => {
486528
})}
487529
</Button>
488530
</AuthorizedEle>,
531+
<AuthorizedEle key="1" judgeKey={functionKeys.App_Read}>
532+
<Button
533+
key="export"
534+
icon={<DownloadOutlined />}
535+
disabled={selectedRowsState.length === 0}
536+
onClick={async () => {
537+
const success = await handleExportApps(selectedRowsState);
538+
if (success) {
539+
setSelectedRowKeys([]);
540+
setSelectedRows([]);
541+
}
542+
}}
543+
>
544+
导出
545+
</Button>
546+
</AuthorizedEle>,
489547
];
490548
}}
491549
//dataSource={dataSource}

src/AgileConfig.Server.UI/react-ui-antd/src/pages/Apps/service.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,4 +69,14 @@ export async function getAppGroups() {
6969
return request('app/GetAppGroups', {
7070
method: 'GET',
7171
});
72+
}
73+
74+
export async function exportApps(appIds: string[]) {
75+
return request('app/Export', {
76+
method: 'POST',
77+
data: {
78+
appIds,
79+
},
80+
responseType: 'blob',
81+
});
7282
}

test/ApiSiteTests/TestAppController.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,12 @@ public async Task TestAdd()
2626
var logService = new Mock<ISysLogService>();
2727
var userService = new Mock<IUserService>();
2828
var permissionService = new Mock<IPermissionService>();
29+
var configService = new Mock<IConfigService>();
30+
var settingService = new Mock<ISettingService>();
2931
var eventBus = new Mock<ITinyEventBus>();
3032

31-
var ctl = new AppController(appService.Object, userService.Object, eventBus.Object);
33+
var ctl = new AppController(appService.Object, userService.Object, configService.Object, settingService.Object,
34+
eventBus.Object);
3235

3336
ctl.ControllerContext.HttpContext = new DefaultHttpContext();
3437

0 commit comments

Comments
 (0)