更新nuget&&添加swagger

This commit is contained in:
2026-08-09 14:47:08 +08:00 Unverified
parent 1229f3163d
commit 3c9863580b
16 changed files with 6967 additions and 38 deletions
@@ -79,6 +79,38 @@ public sealed class OperationsController(
CancellationToken cancellationToken) =>
Ok(await healthService.CheckAsync(cancellationToken));
[HttpGet("swagger")]
public async Task<ActionResult<SwaggerDocumentationSettings>> GetSwaggerSettings(
CancellationToken cancellationToken) =>
Ok(new SwaggerDocumentationSettings(await IsSwaggerEnabledAsync(cancellationToken)));
[HttpPut("swagger")]
public async Task<ActionResult<SwaggerDocumentationSettings>> UpdateSwaggerSettings(
UpdateSwaggerDocumentationSettings request,
CancellationToken cancellationToken)
{
var setting = await db.SystemFeatureSettings.SingleOrDefaultAsync(
x => x.Key == SystemFeatureKeys.SwaggerDocumentation,
cancellationToken);
if (setting is null)
{
setting = new SystemFeatureSetting
{
Key = SystemFeatureKeys.SwaggerDocumentation,
IsEnabled = request.IsEnabled
};
db.SystemFeatureSettings.Add(setting);
}
else
{
setting.IsEnabled = request.IsEnabled;
setting.UpdatedAt = DateTime.UtcNow;
}
await db.SaveChangesAsync(cancellationToken);
return Ok(new SwaggerDocumentationSettings(setting.IsEnabled));
}
[HttpGet("audit-logs")]
public async Task<ActionResult<PagedResult<AuditLogItem>>> GetAuditLogs(
[FromQuery] int page = 1,
@@ -523,6 +555,12 @@ public sealed class OperationsController(
x.CreatedAt >= from,
cancellationToken);
private async Task<bool> IsSwaggerEnabledAsync(CancellationToken cancellationToken) =>
await db.SystemFeatureSettings.AsNoTracking()
.Where(x => x.Key == SystemFeatureKeys.SwaggerDocumentation)
.Select(x => (bool?)x.IsEnabled)
.SingleOrDefaultAsync(cancellationToken) ?? false;
private ActionResult? ValidatePaging(int page, int pageSize)
{
if (page is < 1 or > 100000 || pageSize is < 1 or > 100)
@@ -604,3 +642,7 @@ public sealed record OperationsSummary(
public sealed record CreateBackupRequest([MaxLength(200)] string? Note);
public sealed record RestoreDrillRequest([Required] string Confirmation);
public sealed record SwaggerDocumentationSettings(bool IsEnabled);
public sealed record UpdateSwaggerDocumentationSettings(bool IsEnabled);
@@ -0,0 +1,14 @@
using Jiaowu.Api.Domain.Common;
namespace Jiaowu.Api.Domain.System;
public static class SystemFeatureKeys
{
public const string SwaggerDocumentation = "SwaggerDocumentation";
}
public sealed class SystemFeatureSetting : EntityBase
{
public required string Key { get; set; }
public bool IsEnabled { get; set; }
}
@@ -137,6 +137,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
Set<BackgroundJobOutboxMessage>();
public DbSet<AppUpdateRelease> AppUpdateReleases =>
Set<AppUpdateRelease>();
public DbSet<SystemFeatureSetting> SystemFeatureSettings =>
Set<SystemFeatureSetting>();
public DbSet<RefreshSession> RefreshSessions => Set<RefreshSession>();
protected override void ConfigureConventions(
@@ -1451,6 +1453,12 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
entity.HasIndex(x => x.CreatedAt);
});
builder.Entity<SystemFeatureSetting>(entity =>
{
entity.Property(x => x.Key).HasMaxLength(100);
entity.HasIndex(x => x.Key).IsUnique();
});
builder.Entity<OfficialDocument>(entity =>
{
entity.Property(x => x.DocumentNumber).HasMaxLength(50);
@@ -90,6 +90,8 @@ public sealed class DevelopmentSqliteMigrator(
"20260809_47_course_grade_distribution";
private const string TeachingTaskGradeAnalyticsMigration =
"20260809_48_teaching_task_grade_analytics";
private const string SwaggerDocumentationSettingMigration =
"20260809_49_swagger_documentation_setting";
public async Task MigrateAsync(CancellationToken cancellationToken = default)
{
@@ -669,6 +671,14 @@ public sealed class DevelopmentSqliteMigrator(
TeachingTaskGradeAnalyticsMigration,
TeachingTaskGradeAnalyticsStatements,
cancellationToken);
var swaggerSettingsExist = await db.Database
.SqlQueryRaw<int>(
"SELECT COUNT(*) AS \"Value\" FROM sqlite_master WHERE type = 'table' AND name = 'SystemFeatureSettings'")
.AnyAsync(value => value > 0, cancellationToken);
await ApplyMigrationAsync(
SwaggerDocumentationSettingMigration,
swaggerSettingsExist ? [] : SwaggerDocumentationSettingStatements,
cancellationToken);
}
private async Task ApplyMigrationAsync(
@@ -2913,4 +2923,21 @@ public sealed class DevelopmentSqliteMigrator(
ADD COLUMN "Kind" INTEGER NOT NULL DEFAULT 1;
"""
];
private static readonly string[] SwaggerDocumentationSettingStatements =
[
"""
CREATE TABLE "SystemFeatureSettings" (
"Id" TEXT NOT NULL CONSTRAINT "PK_SystemFeatureSettings" PRIMARY KEY,
"Key" TEXT NOT NULL,
"IsEnabled" INTEGER NOT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL
);
""",
"""
CREATE UNIQUE INDEX "IX_SystemFeatureSettings_Key"
ON "SystemFeatureSettings" ("Key");
"""
];
}
@@ -0,0 +1,44 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class SwaggerDocumentationSetting : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "SystemFeatureSettings",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
Key = table.Column<string>(type: "varchar(100)", maxLength: 100, nullable: false),
IsEnabled = table.Column<bool>(type: "tinyint(1)", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_SystemFeatureSettings", x => x.Id);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_SystemFeatureSettings_Key",
table: "SystemFeatureSettings",
column: "Key",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "SystemFeatureSettings");
}
}
}
@@ -4767,6 +4767,34 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.ToTable("BackgroundJobOutboxMessages");
});
modelBuilder.Entity("Jiaowu.Api.Domain.System.SystemFeatureSetting", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<bool>("IsEnabled")
.HasColumnType("tinyint(1)");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("Key")
.IsUnique();
b.ToTable("SystemFeatureSettings");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
{
b.Property<int>("Id")
+22 -9
View File
@@ -1,8 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<Import Project="..\..\versions.props" />
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Version>2.3.2-beta.1</Version>
<Version>$(JiaowuBackendVersion)</Version>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<SpaRoot>$([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)/../../web'))</SpaRoot>
@@ -18,15 +20,26 @@
Include="..\..\.env.example"
Link=".env.example"
CopyToPublishDirectory="PreserveNewest" />
<Content
Include="..\..\versions.props"
Link="versions.props"
CopyToOutputDirectory="PreserveNewest"
CopyToPublishDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="ClosedXML" Version="0.105.0" />
<PackageReference Include="DocumentFormat.OpenXml" Version="3.1.1" />
<AssemblyMetadata
Include="SwaggerDocumentVersion"
Value="$(JiaowuSwaggerVersion)" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="ClosedXML" Version="0.105.1" />
<PackageReference Include="DocumentFormat.OpenXml" Version="3.5.1" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.10" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="10.0.10" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.10" />
<PackageReference Include="Microsoft.Extensions.Caching.Hybrid" Version="10.1.0" />
<PackageReference Include="Microsoft.Extensions.Caching.Hybrid" Version="10.8.0" />
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="10.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.10">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
@@ -40,11 +53,11 @@
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.17.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.17.0" />
<PackageReference Include="QRCoder" Version="1.8.0" />
<PackageReference Include="RabbitMQ.Client" Version="7.2.1" />
<PackageReference Include="SkiaSharp" Version="3.119.2" />
<PackageReference Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="3.119.2" />
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.4" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
<PackageReference Include="RabbitMQ.Client" Version="7.2.2" />
<PackageReference Include="SkiaSharp" Version="4.151.1" />
<PackageReference Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="4.151.1" />
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.5" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.2.3" />
</ItemGroup>
<Target
+43 -15
View File
@@ -1,6 +1,7 @@
using System.Text;
using System.Text.Json.Serialization;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Domain.System;
using Jiaowu.Api.Infrastructure.BackgroundJobs;
using Jiaowu.Api.Infrastructure.Grades;
using Jiaowu.Api.Infrastructure.Configuration;
@@ -23,10 +24,11 @@ using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.IdentityModel.Tokens;
using Microsoft.OpenApi.Models;
using Microsoft.OpenApi;
using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
using Swashbuckle.AspNetCore.SwaggerUI;
using System.Threading.RateLimiting;
EnvironmentFile.Load();
@@ -62,6 +64,13 @@ if (confirmProductionDemoData && !seedDemoData)
}
var builder = WebApplication.CreateBuilder(args);
var swaggerDocumentVersion = typeof(Program).Assembly
.GetCustomAttributes(
typeof(System.Reflection.AssemblyMetadataAttribute),
inherit: false)
.OfType<System.Reflection.AssemblyMetadataAttribute>()
.SingleOrDefault(x => x.Key == "SwaggerDocumentVersion")?.Value
?? "v1";
if (seedDemoData && builder.Environment.IsDevelopment())
{
@@ -608,10 +617,10 @@ builder.Services.AddControllers()
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(options =>
{
options.SwaggerDoc("v1", new OpenApiInfo
options.SwaggerDoc(swaggerDocumentVersion, new OpenApiInfo
{
Title = "大学教务管理系统 API",
Version = "v1"
Version = swaggerDocumentVersion
});
options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
{
@@ -621,17 +630,10 @@ builder.Services.AddSwaggerGen(options =>
BearerFormat = "JWT",
In = ParameterLocation.Header
});
options.AddSecurityRequirement(new OpenApiSecurityRequirement
options.AddSecurityRequirement(_ => new OpenApiSecurityRequirement
{
[
new OpenApiSecurityScheme
{
Reference = new OpenApiReference
{
Type = ReferenceType.SecurityScheme,
Id = "Bearer"
}
}
new OpenApiSecuritySchemeReference("Bearer", null, null)
] = []
});
});
@@ -640,11 +642,37 @@ var app = builder.Build();
app.UseExceptionHandler();
app.UseResponseCompression();
if (app.Environment.IsDevelopment())
app.Use(async (context, next) =>
{
app.UseSwagger();
app.UseSwaggerUI();
if (context.Request.Path.StartsWithSegments("/swagger"))
{
var isEnabled = await context.RequestServices
.GetRequiredService<AppDbContext>()
.SystemFeatureSettings
.AsNoTracking()
.Where(x => x.Key == SystemFeatureKeys.SwaggerDocumentation)
.Select(x => (bool?)x.IsEnabled)
.SingleOrDefaultAsync(context.RequestAborted) ?? false;
if (!isEnabled)
{
context.Response.StatusCode = StatusCodes.Status404NotFound;
return;
}
}
await next();
});
app.UseSwagger();
app.UseSwaggerUI(options =>
{
options.SwaggerEndpoint(
$"/swagger/{swaggerDocumentVersion}/swagger.json",
$"大学教务管理系统 API {swaggerDocumentVersion}");
options.DocExpansion(DocExpansion.None);
options.DefaultModelsExpandDepth(-1);
options.DefaultModelExpandDepth(1);
options.EnableFilter();
});
app.UseDefaultFiles();
app.UseStaticFiles(new StaticFileOptions
@@ -10,10 +10,10 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="xunit" Version="2.5.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
<PackageReference Include="coverlet.collector" Version="10.0.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5" />
</ItemGroup>
<ItemGroup>
@@ -33,6 +33,39 @@ public sealed class OperationsControllerTests
Assert.Equal(SystemRoles.SuperAdmin, authorize.Roles);
}
[Fact]
public async Task Swagger_documentation_is_closed_by_default_and_can_be_enabled()
{
var root = CreateTemporaryRoot();
try
{
await using var fixture = await OperationsFixture.CreateAsync(root);
var initial = await fixture.Controller.GetSwaggerSettings(
CancellationToken.None);
var initialSettings = Assert.IsType<SwaggerDocumentationSettings>(
Assert.IsType<OkObjectResult>(initial.Result).Value);
Assert.False(initialSettings.IsEnabled);
var updated = await fixture.Controller.UpdateSwaggerSettings(
new UpdateSwaggerDocumentationSettings(true),
CancellationToken.None);
var updatedSettings = Assert.IsType<SwaggerDocumentationSettings>(
Assert.IsType<OkObjectResult>(updated.Result).Value);
Assert.True(updatedSettings.IsEnabled);
var persisted = await fixture.Controller.GetSwaggerSettings(
CancellationToken.None);
var persistedSettings = Assert.IsType<SwaggerDocumentationSettings>(
Assert.IsType<OkObjectResult>(persisted.Result).Value);
Assert.True(persistedSettings.IsEnabled);
}
finally
{
DeleteTemporaryRoot(root);
}
}
[Fact]
public async Task Audit_and_failed_job_queries_return_operational_records()
{
+7
View File
@@ -0,0 +1,7 @@
<Project>
<PropertyGroup>
<JiaowuBackendVersion>2.3.2-beta.2</JiaowuBackendVersion>
<JiaowuFrontendVersion>2.3.2-beta.2</JiaowuFrontendVersion>
<JiaowuSwaggerVersion>2.3.2-beta.2</JiaowuSwaggerVersion>
</PropertyGroup>
</Project>
+6
View File
@@ -1,12 +1,18 @@
{
"name": "web",
<<<<<<< Updated upstream
"version": "1.0.0",
=======
>>>>>>> Stashed changes
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "web",
<<<<<<< Updated upstream
"version": "1.0.0",
=======
>>>>>>> Stashed changes
"dependencies": {
"@capacitor/android": "^8.4.2",
"@capacitor/barcode-scanner": "^3.1.0",
-1
View File
@@ -1,7 +1,6 @@
{
"name": "web",
"private": true,
"version": "2.3.2-beta.1",
"type": "module",
"scripts": {
"dev": "vite",
+83 -1
View File
@@ -98,12 +98,18 @@ interface PageResult<T> {
pageSize: number
}
interface SwaggerDocumentationSettings {
isEnabled: boolean
}
const loading = ref(true)
const refreshing = ref(false)
const backupBusy = ref(false)
const summary = ref<Summary>()
const backups = ref<BackupArtifact[]>([])
const activeLedger = ref<'audit' | 'jobs'>('audit')
const swaggerEnabled = ref(false)
const swaggerBusy = ref(false)
const auditLoading = ref(false)
const auditRows = ref<AuditLog[]>([])
@@ -204,12 +210,36 @@ function httpStatusType(status: number) {
}
async function loadSummary() {
const [{ data: summaryData }, { data: backupData }] = await Promise.all([
const [{ data: summaryData }, { data: backupData }, { data: swaggerData }] = await Promise.all([
http.get<Summary>('/operations/summary'),
http.get<BackupArtifact[]>('/operations/backups'),
http.get<SwaggerDocumentationSettings>('/operations/swagger'),
])
summary.value = summaryData
backups.value = backupData
swaggerEnabled.value = swaggerData.isEnabled
}
async function updateSwaggerDocumentation(value: string | number | boolean) {
if (typeof value !== 'boolean') return
const isEnabled = value
swaggerBusy.value = true
try {
const { data } = await http.put<SwaggerDocumentationSettings>(
'/operations/swagger',
{ isEnabled },
)
swaggerEnabled.value = data.isEnabled
ElMessage.success(data.isEnabled ? 'Swagger 文档已开放。' : 'Swagger 文档已关闭。')
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
swaggerBusy.value = false
}
}
function openSwaggerDocumentation() {
window.open('/swagger', '_blank', 'noopener')
}
async function loadAudit() {
@@ -371,6 +401,35 @@ onMounted(refreshAll)
</el-button>
</header>
<section class="swagger-panel">
<div>
<span>API DOCUMENTATION</span>
<h3>Swagger 文档</h3>
<p>
{{ swaggerEnabled
? '文档与 OpenAPI 定义当前已对外开放。'
: '文档当前关闭,访问 /swagger 将返回 404。' }}
</p>
</div>
<div class="swagger-actions">
<el-switch
:model-value="swaggerEnabled"
:loading="swaggerBusy"
active-text="已开放"
inactive-text="已关闭"
@change="updateSwaggerDocumentation"
/>
<el-button
v-if="swaggerEnabled"
type="primary"
plain
@click="openSwaggerDocumentation"
>
打开文档
</el-button>
</div>
</section>
<section class="signal-board" aria-label="系统健康链路">
<div class="signal-summary">
<span>当前值守结论</span>
@@ -670,6 +729,28 @@ onMounted(refreshAll)
padding: 4px 2px 16px;
}
.swagger-panel {
align-items: center;
background: #fff;
border: 1px solid var(--line);
border-left: 4px solid var(--signal);
display: flex;
justify-content: space-between;
gap: 18px;
padding: 18px 20px;
}
.swagger-panel span {
color: var(--signal);
font-size: 11px;
font-weight: 700;
letter-spacing: .08em;
}
.swagger-panel h3 { margin: 4px 0; }
.swagger-panel p { color: var(--muted); margin: 0; }
.swagger-actions { align-items: center; display: flex; flex-wrap: wrap; gap: 12px; }
.console-kicker,
.panel-heading span,
.signal-summary > span,
@@ -1011,6 +1092,7 @@ onMounted(refreshAll)
@media (max-width: 560px) {
.operations-console { gap: 12px; }
.console-heading { align-items: flex-start; gap: 14px; }
.swagger-panel { align-items: flex-start; flex-direction: column; }
.console-heading p { font-size: 12px; line-height: 1.5; }
.signal-track { display: block; }
.signal-node { border-bottom: 1px solid #e4e9ea; padding: 13px 12px; }
+7 -4
View File
@@ -5,9 +5,12 @@ import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
const packageJson = JSON.parse(
readFileSync(new URL('./package.json', import.meta.url), 'utf8'),
) as { version: string }
const versions = readFileSync(new URL('../versions.props', import.meta.url), 'utf8')
const frontendVersion = /<JiaowuFrontendVersion>([^<]+)<\/JiaowuFrontendVersion>/.exec(versions)?.[1]
if (!frontendVersion) {
throw new Error('versions.props 中缺少 JiaowuFrontendVersion。')
}
// https://vite.dev/config/
export default defineConfig(({ mode }) => {
@@ -16,7 +19,7 @@ export default defineConfig(({ mode }) => {
return {
define: {
__APP_VERSION__: JSON.stringify(packageJson.version),
__APP_VERSION__: JSON.stringify(frontendVersion),
},
plugins: [
vue(),