主要能力: SuperAdmin 专用入口:组织与权限 → 运维与审计。 操作日志分页查询,支持时间、账号、路径、方法和状态码筛选。 汇总自动排课、课表发布、补考安排三类失败后台任务。 实时检查数据库、缓存、任务通道及积压状态。 聚合 5xx、失败/重试任务、健康探针和备份时效告警。 SQLite 在线备份;MySQL 调用原生客户端备份。 SHA-256 校验及隔离数据库恢复演练,不覆盖业务库。 MySQL 强制使用独立运维连接,容器增加持久化备份卷与数据库客户端。
253 lines
9.0 KiB
C#
253 lines
9.0 KiB
C#
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();
|
|
}
|
|
}
|