“运维与审计控制台”。
主要能力: SuperAdmin 专用入口:组织与权限 → 运维与审计。 操作日志分页查询,支持时间、账号、路径、方法和状态码筛选。 汇总自动排课、课表发布、补考安排三类失败后台任务。 实时检查数据库、缓存、任务通道及积压状态。 聚合 5xx、失败/重试任务、健康探针和备份时效告警。 SQLite 在线备份;MySQL 调用原生客户端备份。 SHA-256 校验及隔离数据库恢复演练,不覆盖业务库。 MySQL 强制使用独立运维连接,容器增加持久化备份卷与数据库客户端。
This commit is contained in:
@@ -0,0 +1,252 @@
|
||||
using System.Reflection;
|
||||
using Jiaowu.Api.Contracts;
|
||||
using Jiaowu.Api.Controllers;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Domain.System;
|
||||
using Jiaowu.Api.Infrastructure.BackgroundJobs;
|
||||
using Jiaowu.Api.Infrastructure.Caching;
|
||||
using Jiaowu.Api.Infrastructure.Operations;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.FileProviders;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jiaowu.Api.Tests;
|
||||
|
||||
public sealed class OperationsControllerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Controller_is_restricted_to_super_admin()
|
||||
{
|
||||
var authorize = typeof(OperationsController)
|
||||
.GetCustomAttribute<AuthorizeAttribute>();
|
||||
|
||||
Assert.NotNull(authorize);
|
||||
Assert.Equal(SystemRoles.SuperAdmin, authorize.Roles);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Audit_and_failed_job_queries_return_operational_records()
|
||||
{
|
||||
var root = CreateTemporaryRoot();
|
||||
try
|
||||
{
|
||||
await using var fixture = await OperationsFixture.CreateAsync(root);
|
||||
var term = new AcademicTerm
|
||||
{
|
||||
Code = "OPS-TERM",
|
||||
Name = "运维测试学期",
|
||||
AcademicYear = "2026-2027",
|
||||
Season = TermSeason.Autumn,
|
||||
StartDate = new DateOnly(2026, 9, 1),
|
||||
EndDate = new DateOnly(2027, 1, 15)
|
||||
};
|
||||
var plan = new SchedulePlan
|
||||
{
|
||||
AcademicTerm = term,
|
||||
Name = "恢复演练排课方案",
|
||||
Version = "V1"
|
||||
};
|
||||
var job = new AutomaticScheduleJob
|
||||
{
|
||||
SchedulePlan = plan,
|
||||
Status = AutomaticScheduleJobStatus.Failed,
|
||||
ErrorMessage = "排课求解器未找到可行解。",
|
||||
CompletedAt = DateTime.UtcNow
|
||||
};
|
||||
var outbox = BackgroundJobOutboxMessage.Create(
|
||||
BackgroundJobKind.AutomaticSchedule,
|
||||
job.Id);
|
||||
outbox.State = BackgroundJobOutboxState.Completed;
|
||||
outbox.ProcessingAttempts = 3;
|
||||
fixture.Db.AuditLogs.Add(new AuditLog
|
||||
{
|
||||
UserName = "root",
|
||||
Method = "POST",
|
||||
Path = "/api/schedules",
|
||||
StatusCode = 500,
|
||||
IpAddress = "127.0.0.1"
|
||||
});
|
||||
fixture.Db.AddRange(term, plan, job, outbox);
|
||||
await fixture.Db.SaveChangesAsync();
|
||||
|
||||
var auditAction = await fixture.Controller.GetAuditLogs(
|
||||
cancellationToken: CancellationToken.None);
|
||||
var auditResult = Assert.IsType<OkObjectResult>(auditAction.Result);
|
||||
var auditPage = Assert.IsType<PagedResult<AuditLogItem>>(auditResult.Value);
|
||||
Assert.Single(auditPage.Items);
|
||||
Assert.Equal(500, auditPage.Items.Single().StatusCode);
|
||||
|
||||
var jobsAction = await fixture.Controller.GetFailedJobs(
|
||||
cancellationToken: CancellationToken.None);
|
||||
var jobsResult = Assert.IsType<OkObjectResult>(jobsAction.Result);
|
||||
var jobsPage =
|
||||
Assert.IsType<PagedResult<FailedBackgroundJobItem>>(jobsResult.Value);
|
||||
var failed = Assert.Single(jobsPage.Items);
|
||||
Assert.Equal("AutomaticSchedule", failed.Kind);
|
||||
Assert.Equal(3, failed.ProcessingAttempts);
|
||||
Assert.Contains("可行解", failed.ErrorMessage);
|
||||
}
|
||||
finally
|
||||
{
|
||||
DeleteTemporaryRoot(root);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Sqlite_backup_can_be_verified_in_an_isolated_restore_drill()
|
||||
{
|
||||
var root = CreateTemporaryRoot();
|
||||
try
|
||||
{
|
||||
await using var fixture = await OperationsFixture.CreateAsync(root);
|
||||
fixture.Db.AuditLogs.Add(new AuditLog
|
||||
{
|
||||
UserName = "backup-test",
|
||||
Method = "POST",
|
||||
Path = "/api/test",
|
||||
StatusCode = 204
|
||||
});
|
||||
await fixture.Db.SaveChangesAsync();
|
||||
|
||||
var artifact = await fixture.Backups.CreateAsync(
|
||||
"自动化测试",
|
||||
CancellationToken.None);
|
||||
var result = await fixture.Backups.RunRestoreDrillAsync(
|
||||
artifact.Id,
|
||||
CancellationToken.None);
|
||||
var listed = await fixture.Backups.ListAsync(CancellationToken.None);
|
||||
|
||||
Assert.True(File.Exists(Path.Combine(
|
||||
root,
|
||||
"backups",
|
||||
artifact.FileName)));
|
||||
Assert.Equal(64, artifact.Sha256.Length);
|
||||
Assert.True(result.Succeeded, result.Detail);
|
||||
Assert.True(result.TableCount > 0);
|
||||
Assert.True(Assert.Single(listed).LastDrillSucceeded);
|
||||
}
|
||||
finally
|
||||
{
|
||||
DeleteTemporaryRoot(root);
|
||||
}
|
||||
}
|
||||
|
||||
private static string CreateTemporaryRoot()
|
||||
{
|
||||
var root = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
$"jiaowu-operations-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
private static void DeleteTemporaryRoot(string root)
|
||||
{
|
||||
var resolved = Path.GetFullPath(root);
|
||||
var temporary = Path.GetFullPath(Path.GetTempPath());
|
||||
Assert.StartsWith(temporary, resolved, StringComparison.OrdinalIgnoreCase);
|
||||
if (Directory.Exists(resolved))
|
||||
Directory.Delete(resolved, recursive: true);
|
||||
}
|
||||
|
||||
private sealed class OperationsFixture : IAsyncDisposable
|
||||
{
|
||||
private readonly ServiceProvider provider;
|
||||
|
||||
private OperationsFixture(
|
||||
ServiceProvider provider,
|
||||
AppDbContext db,
|
||||
OperationsController controller,
|
||||
DatabaseBackupService backups)
|
||||
{
|
||||
this.provider = provider;
|
||||
Db = db;
|
||||
Controller = controller;
|
||||
Backups = backups;
|
||||
}
|
||||
|
||||
public AppDbContext Db { get; }
|
||||
public OperationsController Controller { get; }
|
||||
public DatabaseBackupService Backups { get; }
|
||||
|
||||
public static async Task<OperationsFixture> CreateAsync(string root)
|
||||
{
|
||||
var databasePath = Path.Combine(root, "source.sqlite");
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["ConnectionStrings:SQLite"] =
|
||||
$"Data Source={databasePath};Pooling=False"
|
||||
})
|
||||
.Build();
|
||||
var databaseOptions = new DatabaseOptions { Provider = "SQLite" };
|
||||
var cacheOptions = new AppCacheOptions();
|
||||
var operationsOptions = new OperationsOptions
|
||||
{
|
||||
BackupDirectory = "backups"
|
||||
};
|
||||
var dbContextOptions = new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseSqlite(configuration.GetConnectionString("SQLite"))
|
||||
.Options;
|
||||
var db = new AppDbContext(dbContextOptions);
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
|
||||
var services = new ServiceCollection()
|
||||
.AddLogging()
|
||||
.BuildServiceProvider();
|
||||
var logger = services.GetRequiredService<
|
||||
ILogger<DatabaseBackupService>>();
|
||||
var environment = new TestHostEnvironment
|
||||
{
|
||||
ContentRootPath = root
|
||||
};
|
||||
var transport = new InMemoryBackgroundJobTransport();
|
||||
var monitoring = new BackgroundJobMonitoringService(db);
|
||||
var health = new OperationalHealthService(
|
||||
db,
|
||||
services,
|
||||
transport,
|
||||
monitoring,
|
||||
databaseOptions,
|
||||
cacheOptions,
|
||||
configuration);
|
||||
var backups = new DatabaseBackupService(
|
||||
databaseOptions,
|
||||
operationsOptions,
|
||||
configuration,
|
||||
environment,
|
||||
logger);
|
||||
var controller = new OperationsController(
|
||||
db,
|
||||
health,
|
||||
backups,
|
||||
operationsOptions);
|
||||
return new OperationsFixture(services, db, controller, backups);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await Db.DisposeAsync();
|
||||
await provider.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestHostEnvironment : IWebHostEnvironment
|
||||
{
|
||||
public string ApplicationName { get; set; } = "Jiaowu.Api.Tests";
|
||||
public IFileProvider WebRootFileProvider { get; set; } = new NullFileProvider();
|
||||
public string WebRootPath { get; set; } = "";
|
||||
public string EnvironmentName { get; set; } = Environments.Development;
|
||||
public string ContentRootPath { get; set; } = "";
|
||||
public IFileProvider ContentRootFileProvider { get; set; } =
|
||||
new NullFileProvider();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user