This commit is contained in:
2026-03-30 11:07:30 +08:00
parent 2c44b3a4b2
commit d4a8e71733
74 changed files with 1751 additions and 421 deletions

View File

@@ -0,0 +1,49 @@
using System.ComponentModel;
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.OpenApi;
using Microsoft.OpenApi;
namespace StopShopping.FileApi.Extensions;
/// <summary>
/// 处理enum类型openapi显示
/// </summary>
public class EnumOpenApiSchemaTransformer : IOpenApiSchemaTransformer
{
public Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext context, CancellationToken cancellationToken)
{
if (context.JsonTypeInfo.Type.IsEnum)
{
schema.Type = JsonSchemaType.Integer;
var enumValues = Enum.GetValues(context.JsonTypeInfo.Type)
.Cast<object>()
.Select(v => JsonNode.Parse(Convert.ToInt32(v).ToString())!)
.ToList();
schema.Enum = enumValues;
var enumNames = Enum.GetNames(context.JsonTypeInfo.Type);
schema.Extensions ??= new Dictionary<string, IOpenApiExtension>();
var namesExtension = new JsonNodeExtension(new JsonArray(
enumNames
.Select(n => (JsonNode)n)
.ToArray()));
schema.Extensions.Add("x-enumNames", namesExtension);
var descMap = new JsonObject();
foreach (var name in enumNames)
{
if (context.JsonTypeInfo.Type.GetField(name)
?.GetCustomAttributes(typeof(DescriptionAttribute), false)
.FirstOrDefault() is DescriptionAttribute attr)
{
descMap[name] = attr.Description;
}
}
schema.Extensions.Add("x-enumDescriptions", new JsonNodeExtension(descMap));
}
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,13 @@
using StopShopping.FileApi.Middlewares;
namespace StopShopping.FileApi.Extensions;
public static class MiddlewareExtensions
{
public static IApplicationBuilder UseInternalOnlyAccess(this IApplicationBuilder applicationBuilder)
{
applicationBuilder.UseMiddleware<InternalAccessOnlyMiddleware>();
return applicationBuilder;
}
}

View File

@@ -0,0 +1,13 @@
namespace StopShopping.FileApi.Extensions;
public static class RouteGroupBuilderExtensions
{
public static RouteGroupBuilder WithInternalOnly(this RouteGroupBuilder routes)
{
routes.WithMetadata(new InternalOnlyMetadata());
return routes;
}
}
public class InternalOnlyMetadata { }

View File

@@ -0,0 +1,58 @@
using System.Net;
using Microsoft.AspNetCore.Mvc;
using StopShopping.FileApi.Extensions;
namespace StopShopping.FileApi.Middlewares;
public class InternalAccessOnlyMiddleware
{
public InternalAccessOnlyMiddleware(
RequestDelegate next,
IProblemDetailsService problemDetailsService,
ILogger<InternalAccessOnlyMiddleware> logger)
{
_next = next;
_problemService = problemDetailsService;
_logger = logger;
}
private readonly RequestDelegate _next;
private readonly IProblemDetailsService _problemService;
private readonly ILogger<InternalAccessOnlyMiddleware> _logger;
public async Task InvokeAsync(HttpContext httpContext)
{
var endpoint = httpContext.GetEndpoint();
if (null != endpoint)
{
var internalOnlyMetadata = endpoint.Metadata.GetMetadata<InternalOnlyMetadata>();
if (null != internalOnlyMetadata)
{
if (null == httpContext.Connection.RemoteIpAddress
|| !IPAddress.IsLoopback(httpContext.Connection.RemoteIpAddress))
{
var problemDetails = new ProblemDetails
{
Detail = $"remote ip: {httpContext.Connection.RemoteIpAddress}",
Instance = httpContext.Request.Path,
Status = StatusCodes.Status403Forbidden,
Title = "access denied, local access only."
};
httpContext.Response.StatusCode = StatusCodes.Status403Forbidden;
httpContext.Response.ContentType = "application/problem+json";
await _problemService.WriteAsync(new ProblemDetailsContext
{
HttpContext = httpContext,
ProblemDetails = problemDetails,
});
_logger.LogInformation("denied access: {Ip}", httpContext.Connection.RemoteIpAddress);
}
}
}
await _next(httpContext);
}
}

View File

@@ -0,0 +1,69 @@
using Microsoft.AspNetCore.HostFiltering;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.Extensions.Options;
using Scalar.AspNetCore;
using Serilog;
using StopShopping.FileApi;
using StopShopping.FileApi.Extensions;
using StopShopping.FileApi.Services;
// 将启动日志写入控制台用于捕获启动时异常启动后WriteTo被后续配置替代
Log.Logger = new LoggerConfiguration()
.WriteTo.Console()
.CreateBootstrapLogger();
try
{
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSerilog((services, seriLogger) =>
{
seriLogger.ReadFrom.Configuration(builder.Configuration)
.ReadFrom.Services(services);
});
builder.Services.AddOpenApi(options =>
{
options.AddSchemaTransformer<EnumOpenApiSchemaTransformer>();
});
builder.Services.AddProblemDetails();
builder.Services.AddServices(builder.Configuration.GetSection("AppOptions"));
/********************************************************************/
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.MapScalarApiReference();
}
if (!app.Environment.IsDevelopment())
{
app.UseHostFiltering();
var forwardedHeadersOptions = new ForwardedHeadersOptions
{
ForwardedHeaders = ForwardedHeaders.All,
};
var hostFilteringOptions = app.Services.GetRequiredService<IOptions<HostFilteringOptions>>();
if (null != hostFilteringOptions)
forwardedHeadersOptions.AllowedHosts = hostFilteringOptions.Value.AllowedHosts;
app.UseForwardedHeaders(forwardedHeadersOptions);
}
app.UseStaticFiles();
Routes.MapRoutes(app);
app.UseInternalOnlyAccess();
app.Run();
}
catch (Exception ex)
{
Log.Fatal(ex, "启动异常!");
}
finally
{
Log.CloseAndFlush();
}

View File

@@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5072",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:7178;http://localhost:5072",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,31 @@
using Microsoft.AspNetCore.Mvc;
using StopShopping.FileApi.Extensions;
using StopShopping.FileApi.Services;
namespace StopShopping.FileApi;
public static class Routes
{
public static void MapRoutes(WebApplication app)
{
app.MapGroup("")
.MapRoutes()
.WithInternalOnly();
}
public static RouteGroupBuilder MapRoutes(this RouteGroupBuilder routes)
{
routes.MapPost("/upload", UploadAsync)
.DisableAntiforgery()
.WithDescription("上传文件对外接口自己实现anti-forgery重要");
return routes;
}
private static async Task<ApiResponse<FileUploadResp>> UploadAsync(
[FromForm] UploadParams payload,
IFileService fileService)
{
return await fileService.UploadFileAsync(payload);
}
}

View File

@@ -0,0 +1,64 @@
namespace StopShopping.FileApi.Services;
/// <summary>
/// 强类型返回值
/// </summary>
/// <typeparam name="T"></typeparam>
public class ApiResponse<T>
{
public ApiResponse()
{ }
public ApiResponse(T data)
{
Data = data;
}
/// <summary>
/// 是否成功
/// </summary>
/// <value></value>
public bool IsSucced { get; set; } = true;
/// <summary>
/// 错误消息
/// </summary>
/// <value></value>
public string? Message { get; set; }
/// <summary>
/// 关联数据
/// </summary>
/// <value></value>
public T? Data { get; set; }
public ApiResponse<T> Failed(string message)
{
IsSucced = false;
Message = message;
return this;
}
}
/// <summary>
/// 强类型返回值,只返回成功与否和消息
/// </summary>
public class ApiResponse : ApiResponse<object?>
{
public ApiResponse(bool isSucced = true, string message = "")
{
IsSucced = isSucced;
Message = message;
}
public static ApiResponse Succed(string message = "")
{
return new ApiResponse(message: message);
}
public static new ApiResponse Failed(string message)
{
return new ApiResponse(false, message);
}
}

View File

@@ -0,0 +1,6 @@
namespace StopShopping.FileApi.Services;
public class AppOptions
{
public string Domain { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,34 @@
using FileSignatures;
using FileSignatures.Formats;
using StopShopping.FileApi.Services.Implementions;
namespace StopShopping.FileApi.Services;
public static class Extensions
{
public static IServiceCollection AddServices(this IServiceCollection services, IConfiguration appOptions)
{
services.Configure<AppOptions>(appOptions);
services.AddValidation();
var imageFormats = FileFormatLocator.GetFormats().OfType<Image>();
var imageInspector = new FileFormatInspector(imageFormats);
services.AddSingleton<IFileFormatInspector>(imageInspector);
services.AddScoped<IFileService, FileService>();
return services;
}
public static string GetTargetDirectory(this UploadScences uploadScences)
{
return uploadScences switch
{
UploadScences.Avatar => "avatar",
UploadScences.Product => "product",
UploadScences.Category => "category",
_ => throw new ArgumentOutOfRangeException(nameof(uploadScences))
};
}
}

View File

@@ -0,0 +1,18 @@
namespace StopShopping.FileApi.Services;
/// <summary>
/// 文件上传结果
/// </summary>
public class FileUploadResp
{
/// <summary>
/// 新名,上传后重命名
/// </summary>
/// <value></value>
public string NewName { get; set; } = string.Empty;
/// <summary>
/// Url
/// </summary>
/// <value></value>
public string Url { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,14 @@
namespace StopShopping.FileApi.Services;
/// <summary>
/// 文件服务
/// </summary>
public interface IFileService
{
/// <summary>
/// 上传文件
/// </summary>
/// <param name="payload"></param>
/// <returns></returns>
Task<ApiResponse<FileUploadResp>> UploadFileAsync(UploadParams payload);
}

View File

@@ -0,0 +1,59 @@
using Microsoft.Extensions.Options;
namespace StopShopping.FileApi.Services.Implementions;
public class FileService : IFileService
{
public FileService(IOptions<AppOptions> appOptions,
IWebHostEnvironment webHostEnvironment)
{
_appOptions = appOptions.Value;
_env = webHostEnvironment;
}
private readonly IWebHostEnvironment _env;
private readonly AppOptions _appOptions;
public async Task<ApiResponse<FileUploadResp>> UploadFileAsync(UploadParams payload)
{
var newName = Guid.NewGuid().ToString("N").ToLower();
var extension = Path.GetExtension(payload.File!.FileName);
var newFullName = $"{newName}{extension}";
var relativeToRootPath = GetRelativeToRootPath(payload.Scences, newFullName);
var targetPath = Path.Combine(_env.WebRootPath, GetRelativeToRootPath(payload.Scences));
if (!Directory.Exists(targetPath))
{
Directory.CreateDirectory(targetPath);
}
using var file = new FileStream(
Path.Combine(_env.WebRootPath, relativeToRootPath),
FileMode.CreateNew,
FileAccess.Write);
await payload.File.CopyToAsync(file);
FileUploadResp result = new()
{
NewName = newFullName,
Url = GetFileUrl(payload.Scences, newFullName),
};
return new ApiResponse<FileUploadResp>(result);
}
private string GetFileUrl(UploadScences scences, string fileName)
{
return $"{_appOptions.Domain}/{GetRelativeToRootPath(scences, fileName)
.Replace(Path.DirectorySeparatorChar, '/')}";
}
private string GetRelativeToRootPath(UploadScences scences, string fileName = "")
{
return Path.Combine(
"images",
scences.GetTargetDirectory(),
fileName);
}
}

View File

@@ -0,0 +1,22 @@
using System.ComponentModel.DataAnnotations;
namespace StopShopping.FileApi.Services;
/// <summary>
/// url查询参数
/// </summary>
/// <value></value>
public record NameUrlParams
{
/// <summary>
/// 场景
/// </summary>
/// <value></value>
public UploadScences Scences { get; set; }
/// <summary>
/// 文件名
/// </summary>
/// <value></value>
[Required]
public string[] Names { get; set; } = [];
}

View File

@@ -0,0 +1,14 @@
namespace StopShopping.FileApi.Services;
/// <summary>
/// url查询响应
/// </summary>
/// <value></value>
public class NameUrlResp
{
/// <summary>
/// 文件名:文件链接
/// </summary>
/// <value></value>
public KeyValuePair<string, string>[] NameUrls { get; set; } = [];
}

View File

@@ -0,0 +1,23 @@
using System.ComponentModel.DataAnnotations;
namespace StopShopping.FileApi.Services;
/// <summary>
/// 上传
/// </summary>
/// <value></value>
public record UploadParams
{
/// <summary>
/// 场景
/// </summary>
/// <value></value>
public UploadScences Scences { get; set; }
/// <summary>
/// 文件
/// </summary>
/// <value></value>
[Required]
[ImageFileValidation(2 * 1024 * 1024)]
public IFormFile? File { get; set; }
}

View File

@@ -0,0 +1,20 @@
namespace StopShopping.FileApi.Services;
/// <summary>
/// 文件上传场景
/// </summary>
public enum UploadScences
{
/// <summary>
/// 头像
/// </summary>
Avatar,
/// <summary>
/// 商品
/// </summary>
Product,
/// <summary>
/// 商品分类
/// </summary>
Category,
}

View File

@@ -0,0 +1,49 @@
using FileSignatures;
namespace System.ComponentModel.DataAnnotations;
public class ImageFileValidationAttribute : ValidationAttribute
{
public ImageFileValidationAttribute(long length)
{
Length = length;
}
public long Length { get; set; }
protected override ValidationResult? IsValid(object? value, ValidationContext validationContext)
{
var fileInspector = validationContext.GetRequiredService<IFileFormatInspector>();
if (null == fileInspector)
{
return new ValidationResult("未配置文件验证器");
}
if (value is IFormFile file)
{
var result = Validate(fileInspector, file);
if (null != result)
return result;
}
else if (value is IFormFileCollection files)
{
foreach (var f in files)
{
var result = Validate(fileInspector, f);
if (null != result)
return result;
}
}
return ValidationResult.Success;
}
private ValidationResult? Validate(IFileFormatInspector fileInspector, IFormFile file)
{
if (file.Length > Length)
return new ValidationResult("文件太大");
var format = fileInspector.DetermineFileFormat(file.OpenReadStream());
if (null == format)
return new ValidationResult("文件格式不支持");
return null;
}
}

View File

@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.1" />
<PackageReference Include="Scalar.AspNetCore" Version="2.13.5" />
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageReference Include="FileSignatures" Version="7.0.0" />
</ItemGroup>
<ItemGroup>
<Content Update="wwwroot\**\*">
<CopyToPublishDirectory>Never</CopyToPublishDirectory>
</Content>
<Content Update="appsettings.Template.json">
<CopyToPublishDirectory>Never</CopyToPublishDirectory>
</Content>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,26 @@
{
"AppOptions": {
"Domain": "DOMAIN"
},
"Serilog": {
"Using": ["Serilog.Sinks.File"],
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore": "Warning"
}
},
"WriteTo": [
{
"Name": "File",
"Args": {
"path": "./logs/log-.txt",
"rollingInterval": "Day",
"outputTemplate": "{Timestamp:HH:mm:ss.fff} [{Level:u3}] {SourceContext} - {Message:lj}{NewLine}{Exception}"
}
}
]
},
"AllowedHosts": "*"
}