diff --git a/src/Api/Directory.Build.props b/src/Api/Directory.Build.props new file mode 100644 index 0000000..474a084 --- /dev/null +++ b/src/Api/Directory.Build.props @@ -0,0 +1,5 @@ + + + + + diff --git a/src/Api/TakeoutSaaS.AdminApi/Contracts/Requests/FileUploadFormRequest.cs b/src/Api/TakeoutSaaS.AdminApi/Contracts/Requests/FileUploadFormRequest.cs new file mode 100644 index 0000000..056faa3 --- /dev/null +++ b/src/Api/TakeoutSaaS.AdminApi/Contracts/Requests/FileUploadFormRequest.cs @@ -0,0 +1,20 @@ +using System.ComponentModel.DataAnnotations; +using Microsoft.AspNetCore.Http; + +namespace TakeoutSaaS.AdminApi.Contracts.Requests; + +/// +/// 文件上传表单请求。 +/// +public sealed record FileUploadFormRequest +{ + /// + /// 上传文件。 + /// + [Required] + public required IFormFile File { get; init; } + /// + /// 上传类型。 + /// + public string? Type { get; init; } +} diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/FilesController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/FilesController.cs index cd92566..35f57f5 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/FilesController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/FilesController.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using TakeoutSaaS.AdminApi.Contracts.Requests; using TakeoutSaaS.Application.Storage.Abstractions; using TakeoutSaaS.Application.Storage.Contracts; using TakeoutSaaS.Application.Storage.Extensions; @@ -22,32 +23,29 @@ public sealed class FilesController(IFileStorageService fileStorageService) : Ba /// /// 文件上传响应信息。 [HttpPost("upload")] + [Consumes("multipart/form-data")] [RequestFormLimits(MultipartBodyLengthLimit = 30 * 1024 * 1024)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status400BadRequest)] - public async Task> Upload([FromForm] IFormFile? file, [FromForm] string? type, CancellationToken cancellationToken) + public async Task> Upload([FromForm] FileUploadFormRequest request, CancellationToken cancellationToken) { // 1. 校验文件有效性 - if (file == null || file.Length == 0) + if (request.File is null || request.File.Length == 0) { return ApiResponse.Error(ErrorCodes.BadRequest, "文件不能为空"); } - // 2. 解析上传类型 - if (!UploadFileTypeParser.TryParse(type, out var uploadType)) + if (!UploadFileTypeParser.TryParse(request.Type, out var uploadType)) { return ApiResponse.Error(ErrorCodes.BadRequest, "上传类型不合法"); } - // 3. 提取请求来源 var origin = Request.Headers["Origin"].FirstOrDefault() ?? Request.Headers["Referer"].FirstOrDefault(); - await using var stream = file.OpenReadStream(); - + await using var stream = request.File.OpenReadStream(); // 4. 调用存储服务执行上传 var result = await fileStorageService.UploadAsync( - new UploadFileRequest(uploadType, stream, file.FileName, file.ContentType ?? string.Empty, file.Length, origin), + new UploadFileRequest(uploadType, stream, request.File.FileName, request.File.ContentType ?? string.Empty, request.File.Length, origin), cancellationToken); - // 5. 返回上传结果 return ApiResponse.Ok(result); } diff --git a/src/Api/TakeoutSaaS.MiniApi/Contracts/Requests/FileUploadFormRequest.cs b/src/Api/TakeoutSaaS.MiniApi/Contracts/Requests/FileUploadFormRequest.cs new file mode 100644 index 0000000..8cc3e95 --- /dev/null +++ b/src/Api/TakeoutSaaS.MiniApi/Contracts/Requests/FileUploadFormRequest.cs @@ -0,0 +1,20 @@ +using System.ComponentModel.DataAnnotations; +using Microsoft.AspNetCore.Http; + +namespace TakeoutSaaS.MiniApi.Contracts.Requests; + +/// +/// 文件上传表单请求。 +/// +public sealed record FileUploadFormRequest +{ + /// + /// 上传文件。 + /// + [Required] + public required IFormFile File { get; init; } + /// + /// 上传类型。 + /// + public string? Type { get; init; } +} diff --git a/src/Api/TakeoutSaaS.MiniApi/Controllers/FilesController.cs b/src/Api/TakeoutSaaS.MiniApi/Controllers/FilesController.cs index 0d651fb..c06c440 100644 --- a/src/Api/TakeoutSaaS.MiniApi/Controllers/FilesController.cs +++ b/src/Api/TakeoutSaaS.MiniApi/Controllers/FilesController.cs @@ -6,6 +6,7 @@ using TakeoutSaaS.Application.Storage.Extensions; using TakeoutSaaS.Shared.Abstractions.Constants; using TakeoutSaaS.Shared.Abstractions.Results; using TakeoutSaaS.Shared.Web.Api; +using TakeoutSaaS.MiniApi.Contracts.Requests; namespace TakeoutSaaS.MiniApi.Controllers; @@ -20,37 +21,33 @@ public sealed class FilesController(IFileStorageService fileStorageService) : Ba /// /// 上传图片或文件。 /// - /// 上传文件。 - /// 上传类型。 + /// 表单请求,包含文件与类型。 /// 取消标记。 /// 上传结果,包含访问链接等信息。 [HttpPost("upload")] + [Consumes("multipart/form-data")] [RequestFormLimits(MultipartBodyLengthLimit = 30 * 1024 * 1024)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status400BadRequest)] - public async Task> Upload([FromForm] IFormFile? file, [FromForm] string? type, CancellationToken cancellationToken) + public async Task> Upload([FromForm] FileUploadFormRequest request, CancellationToken cancellationToken) { // 1. 校验文件有效性 - if (file == null || file.Length == 0) + if (request.File is null || request.File.Length == 0) { return ApiResponse.Error(ErrorCodes.BadRequest, "文件不能为空"); } - // 2. 解析上传类型 - if (!UploadFileTypeParser.TryParse(type, out var uploadType)) + if (!UploadFileTypeParser.TryParse(request.Type, out var uploadType)) { return ApiResponse.Error(ErrorCodes.BadRequest, "上传类型不合法"); } - // 3. 提取请求来源 var origin = Request.Headers["Origin"].FirstOrDefault() ?? Request.Headers["Referer"].FirstOrDefault(); - await using var stream = file.OpenReadStream(); - + await using var stream = request.File.OpenReadStream(); // 4. 调用存储服务执行上传 var result = await fileStorageService.UploadAsync( - new UploadFileRequest(uploadType, stream, file.FileName, file.ContentType ?? string.Empty, file.Length, origin), + new UploadFileRequest(uploadType, stream, request.File.FileName, request.File.ContentType ?? string.Empty, request.File.Length, origin), cancellationToken); - // 5. 返回上传结果 return ApiResponse.Ok(result); } diff --git a/src/Core/TakeoutSaaS.Shared.Web/Extensions/ServiceCollectionExtensions.cs b/src/Core/TakeoutSaaS.Shared.Web/Extensions/ServiceCollectionExtensions.cs index 7ac65ce..842728d 100644 --- a/src/Core/TakeoutSaaS.Shared.Web/Extensions/ServiceCollectionExtensions.cs +++ b/src/Core/TakeoutSaaS.Shared.Web/Extensions/ServiceCollectionExtensions.cs @@ -1,9 +1,10 @@ +using Asp.Versioning; +using Asp.Versioning.ApiExplorer; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.DependencyInjection; using TakeoutSaaS.Shared.Abstractions.Security; using TakeoutSaaS.Shared.Web.Filters; using TakeoutSaaS.Shared.Web.Security; - namespace TakeoutSaaS.Shared.Web.Extensions; /// @@ -16,10 +17,11 @@ public static class ServiceCollectionExtensions /// public static IServiceCollection AddSharedWebCore(this IServiceCollection services) { + // 1. 注册基础上下文与当前用户访问器 services.AddHttpContextAccessor(); services.AddEndpointsApiExplorer(); services.AddScoped(); - + // 2. 注册控制器与全局过滤器 services .AddControllers(options => { @@ -27,25 +29,25 @@ public static class ServiceCollectionExtensions options.Filters.Add(); }) .AddNewtonsoftJson(); - + // 3. 配置模型验证行为 services.Configure(options => { options.SuppressModelStateInvalidFilter = true; }); - - services.AddApiVersioning(options => + // 4. 配置 API 版本化 + var apiVersioningBuilder = services.AddApiVersioning(options => { options.AssumeDefaultVersionWhenUnspecified = true; options.DefaultApiVersion = new ApiVersion(1, 0); options.ReportApiVersions = true; }); - - services.AddVersionedApiExplorer(setup => + // 5. 注册版本化 Api Explorer + apiVersioningBuilder.AddApiExplorer(setup => { setup.GroupNameFormat = "'v'VVV"; setup.SubstituteApiVersionInUrl = true; }); - + // 6. 返回服务集合 return services; } } diff --git a/src/Core/TakeoutSaaS.Shared.Web/Swagger/ConfigureSwaggerOptions.cs b/src/Core/TakeoutSaaS.Shared.Web/Swagger/ConfigureSwaggerOptions.cs index 68590fc..cfa1753 100644 --- a/src/Core/TakeoutSaaS.Shared.Web/Swagger/ConfigureSwaggerOptions.cs +++ b/src/Core/TakeoutSaaS.Shared.Web/Swagger/ConfigureSwaggerOptions.cs @@ -1,9 +1,8 @@ -using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Asp.Versioning.ApiExplorer; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Microsoft.OpenApi; using Swashbuckle.AspNetCore.SwaggerGen; - namespace TakeoutSaaS.Shared.Web.Swagger; /// @@ -15,8 +14,12 @@ internal sealed class ConfigureSwaggerOptions( { private readonly SwaggerDocumentSettings _settings = settings.Value; + /// + /// 根据 API 版本生成 Swagger 文档配置。 + /// public void Configure(SwaggerGenOptions options) { + // 1. 为每个 API 版本注册文档 foreach (var description in provider.ApiVersionDescriptions) { var info = new OpenApiInfo @@ -27,14 +30,13 @@ internal sealed class ConfigureSwaggerOptions( ? $"{_settings.Description}(该版本已弃用)" : _settings.Description }; - options.SwaggerGeneratorOptions.SwaggerDocs[description.GroupName] = info; } + // 2. 配置 JWT 授权信息 if (_settings.EnableAuthorization) { const string bearerSchemeName = "Bearer"; - var scheme = new OpenApiSecurityScheme { Name = "Authorization", @@ -44,7 +46,6 @@ internal sealed class ConfigureSwaggerOptions( Scheme = "bearer", BearerFormat = "JWT" }; - options.AddSecurityDefinition(bearerSchemeName, scheme); options.AddSecurityRequirement(document => { @@ -52,7 +53,6 @@ internal sealed class ConfigureSwaggerOptions( { { new OpenApiSecuritySchemeReference(bearerSchemeName, document, null), new List() } }; - return requirement; }); } diff --git a/src/Core/TakeoutSaaS.Shared.Web/Swagger/SwaggerExtensions.cs b/src/Core/TakeoutSaaS.Shared.Web/Swagger/SwaggerExtensions.cs index 51385fb..29a839c 100644 --- a/src/Core/TakeoutSaaS.Shared.Web/Swagger/SwaggerExtensions.cs +++ b/src/Core/TakeoutSaaS.Shared.Web/Swagger/SwaggerExtensions.cs @@ -1,5 +1,7 @@ +using System; +using System.IO; +using Asp.Versioning.ApiExplorer; using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Mvc.ApiExplorer; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Options; @@ -17,7 +19,16 @@ public static class SwaggerExtensions /// public static IServiceCollection AddSharedSwagger(this IServiceCollection services, Action? configure = null) { - services.AddSwaggerGen(); + // 1. 注册 Swagger 并加载 XML 注释以展示中文文档 + services.AddSwaggerGen(options => + { + var basePath = AppContext.BaseDirectory; + var xmlFiles = Directory.GetFiles(basePath, "*.xml"); + foreach (var xml in xmlFiles) + { + options.IncludeXmlComments(xml, true); + } + }); services.AddSingleton(_ => { var settings = new SwaggerDocumentSettings(); @@ -28,7 +39,6 @@ public static class SwaggerExtensions new ConfigureSwaggerOptions( provider.GetRequiredService(), Options.Create(provider.GetRequiredService()))); - return services; } @@ -39,7 +49,7 @@ public static class SwaggerExtensions { var provider = app.ApplicationServices.GetRequiredService(); var settings = app.ApplicationServices.GetRequiredService(); - + // 1. 注册 Swagger 中间件 app.UseSwagger(); app.UseSwaggerUI(options => { @@ -49,10 +59,9 @@ public static class SwaggerExtensions $"/swagger/{description.GroupName}/swagger.json", $"{settings.Title} {description.ApiVersion}"); } - + // 2. 显示请求耗时 options.DisplayRequestDuration(); }); - return app; } } diff --git a/src/Core/TakeoutSaaS.Shared.Web/TakeoutSaaS.Shared.Web.csproj b/src/Core/TakeoutSaaS.Shared.Web/TakeoutSaaS.Shared.Web.csproj index b387355..53aaa82 100644 --- a/src/Core/TakeoutSaaS.Shared.Web/TakeoutSaaS.Shared.Web.csproj +++ b/src/Core/TakeoutSaaS.Shared.Web/TakeoutSaaS.Shared.Web.csproj @@ -9,13 +9,12 @@ - - + + -