Rename projects to FileShare

This commit is contained in:
2026-05-22 14:29:22 +08:00
parent 8270cf198b
commit 9f8da2c063
154 changed files with 394 additions and 398 deletions
@@ -0,0 +1,138 @@
using FileShare_Common.Core;
using FileShare_EFCore.Database;
using FileShare_EFCore.Models;
using FileShare_Services.Core;
using FileShare_Services.Services.AuthService;
using Microsoft.EntityFrameworkCore;
namespace FileShare_API.Authentication
{
/// <summary>
/// API 鉴权端点服务,实现 <see cref="IApiAuthEndpointService"/>
/// 处理登录、刷新 Token 和登出操作,使用 JWT 与 Refresh Token 机制。
/// </summary>
public sealed class ApiAuthEndpointService(
AppDataContext db,
JwtTokenService jwtTokenService,
RefreshTokenService refreshTokenService) : IApiAuthEndpointService
{
/// <summary>
/// 处理用户登录请求。根据账号(邮箱或用户名)查找或创建用户,
/// 生成 JWT Access Token 和 Refresh Token 并返回。
/// </summary>
/// <param name="ctx">服务端点上下文,包含请求体、请求头等信息。</param>
/// <returns>包含 AccessToken、RefreshToken 及过期时间的认证响应。</returns>
public async Task<IApiResponse> LoginAsync(ApiLoginRequest request, ServiceEndpointContext ctx)
{
if (string.IsNullOrWhiteSpace(request.Account))
{
ctx.StatusCode = 400;
return ResponseHelper.Failure(400, "账号不能为空");
}
var user = await db.Users.FirstOrDefaultAsync(
x => x.Email == request.Account || x.Name == request.Account);
if (user is null)
{
user = new UserEntity
{
Name = request.Account,
Email = request.Account.Contains('@') ? request.Account : null,
};
db.Users.Add(user);
await db.SaveChangesAsync();
}
var roles = NormalizeRoles(request.Roles);
var accessToken = jwtTokenService.CreateAccessToken(user, roles);
var refreshToken = await refreshTokenService.CreateAsync(
user.Id,
ctx.GetHeader("User-Agent"),
GetRemoteIpAddress(ctx));
return ResponseHelper.Ok(new AuthTokenResponse(
accessToken.Token,
refreshToken.Token,
accessToken.ExpiresAt,
refreshToken.Entity.ExpiresAt,
roles), "登录成功");
}
/// <summary>
/// 使用 Refresh Token 轮换新的 Access Token 和 Refresh Token。
/// 旧的 Refresh Token 会被撤销并替换。
/// </summary>
/// <param name="ctx">服务端点上下文,包含请求体中的 RefreshToken。</param>
/// <returns>新的 Token 对;若 Refresh Token 无效则返回 401 错误。</returns>
public async Task<IApiResponse> RefreshAsync(ApiRefreshTokenRequest request, ServiceEndpointContext ctx)
{
var rotated = await refreshTokenService.RotateAsync(
request.RefreshToken,
ctx.GetHeader("User-Agent"),
GetRemoteIpAddress(ctx));
if (rotated is null)
{
ctx.StatusCode = 401;
return ResponseHelper.Failure(401, "刷新 token 无效或已过期");
}
var user = await db.Users.FindAsync(rotated.Value.Entity.UserId);
if (user is null)
{
ctx.StatusCode = 401;
return ResponseHelper.Failure(401, "用户不存在");
}
var roles = new[] { "Admin" };
var accessToken = jwtTokenService.CreateAccessToken(user, roles);
return ResponseHelper.Ok(new AuthTokenResponse(
accessToken.Token,
rotated.Value.Token,
accessToken.ExpiresAt,
rotated.Value.Entity.ExpiresAt,
roles), "刷新成功");
}
/// <summary>
/// 处理用户登出请求,撤销指定的 Refresh Token。
/// </summary>
/// <param name="ctx">服务端点上下文,包含请求体中的 RefreshToken。</param>
/// <returns>登出成功的响应。</returns>
public async Task<IApiResponse> LogoutAsync(ApiLogoutRequest request, ServiceEndpointContext ctx)
{
await refreshTokenService.RevokeAsync(request.RefreshToken);
return ResponseHelper.Succeed("退出成功");
}
/// <summary>
/// 从上下文的 Items 中提取 ASP.NET Core HttpContext,并获取客户端远程 IP 地址。
/// </summary>
/// <param name="ctx">服务端点上下文。</param>
/// <returns>客户端 IP 地址字符串;若无法获取则返回 null。</returns>
private static string? GetRemoteIpAddress(ServiceEndpointContext ctx)
{
return ctx.Items.TryGetValue("HttpContext", out var value) && value is HttpContext httpContext
? httpContext.Connection.RemoteIpAddress?.ToString()
: null;
}
/// <summary>
/// 规范化角色数组:去空白、去重(忽略大小写),为空时默认返回 Admin 角色。
/// </summary>
/// <param name="roles">原始角色数组,可为 null。</param>
/// <returns>规范化后的角色数组。</returns>
private static string[] NormalizeRoles(string[]? roles)
{
var normalized = roles?
.Where(role => !string.IsNullOrWhiteSpace(role))
.Select(role => role.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
return normalized is { Length: > 0 } ? normalized : ["Admin"];
}
}
}
@@ -0,0 +1,33 @@
namespace FileShare_API.Authentication
{
/// <summary>
/// JWT 鉴权配置选项,从 appsettings.json 的 Jwt 节绑定。
/// </summary>
public sealed class JwtOptions
{
/// <summary>
/// 获取或设置 Token 签发者。
/// </summary>
public string Issuer { get; set; } = "FileShare-API";
/// <summary>
/// 获取或设置 Token 受众。
/// </summary>
public string Audience { get; set; } = "FileShare-Client";
/// <summary>
/// 获取或设置签名密钥(至少 32 字节)。
/// </summary>
public string SigningKey { get; set; } = "change-this-development-signing-key-at-least-32-bytes";
/// <summary>
/// 获取或设置 Access Token 有效期(分钟),默认 60 分钟。
/// </summary>
public int AccessTokenMinutes { get; set; } = 60;
/// <summary>
/// 获取或设置 Refresh Token 有效期(天),默认 30 天。
/// </summary>
public int RefreshTokenDays { get; set; } = 30;
}
}
@@ -0,0 +1,55 @@
using FileShare_EFCore.Models;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
namespace FileShare_API.Authentication
{
/// <summary>
/// JWT Token 服务,负责创建包含用户声明和角色的 Access Token。
/// </summary>
public sealed class JwtTokenService(IOptions<JwtOptions> options)
{
/// <summary>
/// JWT 配置选项。
/// </summary>
private readonly JwtOptions _options = options.Value;
/// <summary>
/// 创建包含用户声明和角色的 JWT Access Token。
/// </summary>
/// <param name="user">用户实体。</param>
/// <param name="roles">角色集合。</param>
/// <returns>包含 Token 字符串和过期时间的元组。</returns>
public (string Token, DateTime ExpiresAt) CreateAccessToken(UserEntity user, IReadOnlyCollection<string> roles)
{
var expiresAt = DateTime.UtcNow.AddMinutes(_options.AccessTokenMinutes);
var claims = new List<Claim>
{
new(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
new(ClaimTypes.NameIdentifier, user.Id.ToString()),
new(ClaimTypes.Name, user.Name ?? user.Email ?? $"user-{user.Id}"),
new("auth_type", "api-jwt"),
};
foreach (var role in roles.Where(role => !string.IsNullOrWhiteSpace(role)).Distinct(StringComparer.OrdinalIgnoreCase))
{
claims.Add(new Claim(ClaimTypes.Role, role));
}
var signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.SigningKey));
var credentials = new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha256);
var jwt = new JwtSecurityToken(
issuer: _options.Issuer,
audience: _options.Audience,
claims: claims,
notBefore: DateTime.UtcNow,
expires: expiresAt,
signingCredentials: credentials);
return (new JwtSecurityTokenHandler().WriteToken(jwt), expiresAt);
}
}
}
@@ -0,0 +1,124 @@
using FileShare_EFCore.Database;
using FileShare_EFCore.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using System.Security.Cryptography;
using System.Text;
namespace FileShare_API.Authentication
{
/// <summary>
/// Refresh Token 服务,负责创建、查找、撤销和轮换 Refresh Token
/// Token 原文经 SHA256 哈希后存入数据库以保证安全性。
/// </summary>
public sealed class RefreshTokenService(AppDataContext db, IOptions<JwtOptions> options)
{
/// <summary>
/// JWT 配置选项。
/// </summary>
private readonly JwtOptions _options = options.Value;
/// <summary>
/// 创建一个新的 Refresh Token,生成随机 Token 原文并存储其哈希到数据库。
/// </summary>
/// <param name="userId">关联的用户 ID。</param>
/// <param name="device">创建设备标识(如 User-Agent)。</param>
/// <param name="ipAddress">客户端 IP 地址。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>包含 Token 原文和实体记录的元组。</returns>
public async Task<(string Token, ApiRefreshTokenEntity Entity)> CreateAsync(
int userId,
string? device,
string? ipAddress,
CancellationToken cancellationToken = default)
{
var token = Convert.ToBase64String(RandomNumberGenerator.GetBytes(64));
var entity = new ApiRefreshTokenEntity
{
UserId = userId,
TokenHash = HashToken(token),
ExpiresAt = DateTime.UtcNow.AddDays(_options.RefreshTokenDays),
Device = device,
IpAddress = ipAddress,
};
db.ApiRefreshTokens.Add(entity);
await db.SaveChangesAsync(cancellationToken);
return (token, entity);
}
/// <summary>
/// 查找有效的 Refresh Token 实体。Token 原文会被哈希后查询数据库,
/// 仅返回未过期且未被撤销的 Token。
/// </summary>
/// <param name="token">Refresh Token 原文。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>有效的 Token 实体;若无效或不存在则返回 null。</returns>
public async Task<ApiRefreshTokenEntity?> FindActiveAsync(string? token, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(token))
{
return null;
}
var hash = HashToken(token);
var entity = await db.ApiRefreshTokens.FirstOrDefaultAsync(x => x.TokenHash == hash, cancellationToken);
return entity?.IsActive == true ? entity : null;
}
/// <summary>
/// 撤销指定的 Refresh Token,将其 RevokedAt 设为当前时间。
/// </summary>
/// <param name="token">要撤销的 Refresh Token 原文。</param>
/// <param name="cancellationToken">取消令牌。</param>
public async Task RevokeAsync(string? token, CancellationToken cancellationToken = default)
{
var entity = await FindActiveAsync(token, cancellationToken);
if (entity is null)
{
return;
}
entity.RevokedAt = DateTime.UtcNow;
await db.SaveChangesAsync(cancellationToken);
}
/// <summary>
/// 轮换 Refresh Token:撤销旧的并创建新的,将新 Token 的哈希关联到旧记录。
/// </summary>
/// <param name="token">旧的 Refresh Token 原文。</param>
/// <param name="device">当前设备标识。</param>
/// <param name="ipAddress">当前客户端 IP 地址。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>新的 Token 对;若旧 Token 无效则返回 null。</returns>
public async Task<(string Token, ApiRefreshTokenEntity Entity)?> RotateAsync(
string? token,
string? device,
string? ipAddress,
CancellationToken cancellationToken = default)
{
var current = await FindActiveAsync(token, cancellationToken);
if (current is null)
{
return null;
}
var next = await CreateAsync(current.UserId, device, ipAddress, cancellationToken);
current.RevokedAt = DateTime.UtcNow;
current.ReplacedByTokenHash = next.Entity.TokenHash;
await db.SaveChangesAsync(cancellationToken);
return next;
}
/// <summary>
/// 对 Token 原文进行 SHA256 哈希,返回十六进制字符串。
/// </summary>
/// <param name="token">Token 原文。</param>
/// <returns>SHA256 哈希后的十六进制字符串。</returns>
private static string HashToken(string token)
{
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(token));
return Convert.ToHexString(bytes);
}
}
}
@@ -0,0 +1,86 @@
using FileShare_API.Authentication;
using FileShare_API.Services;
using FileShare_EFCore.Database;
using FileShare_Services.Core;
using FileShare_Services.Endpoints;
using FileShare_Services.Services;
using FileShare_Services.Services.AuthService;
using FileShare_Services.Services.FileLibrary;
using FileShare_Services.Services.QrCode;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using System.Text;
namespace FileShare_API.Configuration
{
/// <summary>
/// API 项目服务配置扩展类,负责注册数据库、鉴权、业务服务和统一端点。
/// </summary>
public static class ServicesConfiguration
{
/// <summary>
/// 注册统一端点及其依赖的服务(含数据库)。
/// 所有业务端点定义在 FileShare-Services/Endpoints/AppEndpoints.cs。
/// </summary>
public static IServiceCollection AddUnifiedApiServices(this IServiceCollection services, IConfiguration configuration)
{
// ---- 数据库 ----
// 从 appsettings.json 读取 DatabaseConfiguration 节
// 注册默认数据库提供程序(SQLite / MySQL / PostgreSQL / SqlServer
DatabaseProviderRegistry.RegisterDefaults();
var databaseConfig = configuration
.GetSection(nameof(DatabaseConfiguration))
.Get<DatabaseConfiguration>()
?? DatabaseConfiguration.ForSQLite("app.db");
// 注册 AppDataContext(共享数据上下文)
services.AddAppDatabase<AppDataContext>(databaseConfig);
// ---- 业务服务 ----
services.AddScoped<WeatherForecastService>();
services.AddScoped<IFileLibraryService, FileLibraryService>();
services.AddScoped<IFileLibraryEndpointService, FileLibraryEndpointService>();
services.AddScoped<IFileStreamService, FileStreamService>();
services.AddScoped<IQrCodeService, QrCodeService>();
services.AddHostedService<FileLibraryScanHostedService>();
services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo(Path.Combine(AppContext.BaseDirectory, "data-protection-keys")));
// ---- API 鉴权 ----
var jwtSection = configuration.GetSection("Jwt");
services.Configure<JwtOptions>(jwtSection);
var jwtOptions = jwtSection.Get<JwtOptions>() ?? new JwtOptions();
services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = jwtOptions.Issuer,
ValidAudience = jwtOptions.Audience,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtOptions.SigningKey)),
ClockSkew = TimeSpan.FromMinutes(1),
};
});
services.AddAuthorization();
services.AddScoped<JwtTokenService>();
services.AddScoped<RefreshTokenService>();
services.AddScoped<IApiAuthEndpointService, ApiAuthEndpointService>();
// ---- 统一端点 ----
var endpointBuilder = new ServiceEndpointBuilder();
AppEndpoints.Configure(endpointBuilder);
AuthEndpoints.ConfigureApi(endpointBuilder);
var endpoints = endpointBuilder.Build();
services.AddSingleton(endpoints);
return services;
}
}
}
@@ -0,0 +1,49 @@
using FileShare_Services.Services.FileLibrary;
namespace FileShare_API.Extensions
{
/// <summary>
/// API-only raw file stream endpoints used by browser media elements.
/// </summary>
public static class FileStreamEndpointExtensions
{
/// <summary>
/// Map the media URL emitted by <see cref="FileRecordDto"/>.
/// </summary>
public static IEndpointRouteBuilder MapFileStreamEndpoints(this IEndpointRouteBuilder app)
{
app.MapMethods(
"/api/files/{id:int}/stream",
["GET", "HEAD"],
async (int id, IFileStreamService fileStreamService, HttpContext httpContext) =>
{
var fileResponse = await fileStreamService.GetFileStreamAsync(id);
if (fileResponse is null)
{
return Results.NotFound();
}
var stream = System.IO.File.Open(
fileResponse.FilePath,
FileMode.Open,
FileAccess.Read,
FileShare.ReadWrite);
httpContext.Response.Headers.ContentDisposition =
$"inline; filename=\"{Uri.EscapeDataString(fileResponse.FileName)}\"";
httpContext.Response.Headers.AcceptRanges = "bytes";
httpContext.Response.Headers.CacheControl = "public, max-age=3600";
return Results.File(
stream,
contentType: fileResponse.ContentType,
lastModified: fileResponse.LastModified,
enableRangeProcessing: true);
})
.WithName("StreamManagedFileById")
.WithTags("FileLibrary");
return app;
}
}
}
@@ -0,0 +1,254 @@
using FileShare_Services.Core;
using Microsoft.AspNetCore.Authorization;
using AspNetCoreFilterContext = Microsoft.AspNetCore.Http.EndpointFilterInvocationContext;
using AspNetCoreFilterDelegate = Microsoft.AspNetCore.Http.EndpointFilterDelegate;
// 解决与 ASP.NET Core 同名类型的冲突
using UnifiedFilter = FileShare_Services.Core.IEndpointFilter;
namespace FileShare_API.Extensions
{
/// <summary>
/// 将 FileShare-Services 的统一端点映射到 ASP.NET Core Minimal API。
/// 支持鉴权、过滤器、中间件的完整 ASP.NET Core 管道。
/// </summary>
public static class UnifiedEndpointExtensions
{
/// <summary>
/// 将 ServiceEndpointCollection 中的所有端点注册到 ASP.NET Core 路由。
/// </summary>
public static IEndpointRouteBuilder MapUnifiedEndpoints(
this IEndpointRouteBuilder routeBuilder,
ServiceEndpointCollection endpoints,
IServiceProvider serviceProvider)
{
var apiGroup = routeBuilder.MapGroup("/");
foreach (var endpoint in endpoints.ForHost(EndpointHostTarget.Api))
{
var routeHandlerBuilder = MapEndpoint(apiGroup, endpoint, serviceProvider);
// 全局过滤器 → ASP.NET Core Endpoint Filters
foreach (var globalFilter in endpoints.GlobalFilters)
{
routeHandlerBuilder.AddEndpointFilter(
async (context, next) => await ConvertFilterAsync(globalFilter, context, next));
}
// 端点专属过滤器
foreach (var filter in endpoint.Filters)
{
routeHandlerBuilder.AddEndpointFilter(
async (context, next) => await ConvertFilterAsync(filter, context, next));
}
// 鉴权(使用 ASP.NET Core 原生鉴权机制)
if (endpoint.RequireAuthorization)
{
if (endpoint.Roles.Count > 0)
{
routeHandlerBuilder.RequireAuthorization(new AuthorizeAttribute
{
Roles = string.Join(',', endpoint.Roles),
});
}
else if (!string.IsNullOrEmpty(endpoint.Policy))
{
routeHandlerBuilder.RequireAuthorization(endpoint.Policy);
}
else
{
routeHandlerBuilder.RequireAuthorization();
}
}
if (!string.IsNullOrEmpty(endpoint.Name))
{
routeHandlerBuilder.WithName(endpoint.Name);
}
if (!string.IsNullOrEmpty(endpoint.OpenApiTag))
{
routeHandlerBuilder.WithTags(endpoint.OpenApiTag);
}
if (!string.IsNullOrEmpty(endpoint.OpenApiDescription))
{
routeHandlerBuilder.WithDescription(endpoint.OpenApiDescription);
}
if (!string.IsNullOrWhiteSpace(endpoint.OpenApiSummary))
{
routeHandlerBuilder.WithSummary(endpoint.OpenApiSummary);
}
if (endpoint.OpenApiRequestType is not null
&& endpoint.HttpMethod is "POST" or "PUT")
{
routeHandlerBuilder.Accepts(endpoint.OpenApiRequestType, "application/json");
}
if (endpoint.OpenApiResponseType is not null)
{
routeHandlerBuilder.Produces(200, endpoint.OpenApiResponseType, "application/json");
}
}
return routeBuilder;
}
/// <summary>
/// 根据端点的 HTTP 方法(GET/POST/PUT/DELETE)将其映射到 ASP.NET Core 路由。
/// </summary>
/// <param name="group">路由组。</param>
/// <param name="endpoint">统一端点定义。</param>
/// <param name="serviceProvider">服务提供程序。</param>
/// <returns>路由处理器构建器,用于叠加过滤器等配置。</returns>
private static RouteHandlerBuilder MapEndpoint(
IEndpointRouteBuilder group,
ServiceEndpoint endpoint,
IServiceProvider serviceProvider)
{
var handler = CreateAspNetCoreHandler(endpoint.Handler, serviceProvider);
return endpoint.HttpMethod.ToUpperInvariant() switch
{
"GET" => group.MapGet(endpoint.Pattern, handler),
"POST" => group.MapPost(endpoint.Pattern, handler),
"PUT" => group.MapPut(endpoint.Pattern, handler),
"DELETE" => group.MapDelete(endpoint.Pattern, handler),
_ => group.MapGet(endpoint.Pattern, handler),
};
}
/// <summary>
/// 创建适配 ASP.NET Core 的委托处理器,将统一处理器包装为 ASP.NET Core 可识别的委托。
/// </summary>
/// <param name="unifiedHandler">统一端点处理器。</param>
/// <param name="serviceProvider">服务提供程序。</param>
/// <returns>ASP.NET Core 兼容的委托。</returns>
private static Delegate CreateAspNetCoreHandler(
Func<ServiceEndpointContext, Task<object?>> unifiedHandler,
IServiceProvider serviceProvider)
{
return async (HttpContext httpContext) =>
{
var ctx = httpContext.Items["UnifiedContext"] as ServiceEndpointContext
?? await BuildContextFromHttpContext(httpContext);
ctx.Items["ServiceProvider"] = serviceProvider;
ctx.Items["User"] = httpContext.User;
httpContext.Items["UnifiedContext"] = ctx;
var result = await unifiedHandler(ctx);
// 同步响应状态
httpContext.Response.StatusCode = ctx.StatusCode;
foreach (var kvp in ctx.ResponseHeaders)
{
httpContext.Response.Headers[kvp.Key] = kvp.Value;
}
if (result is FileStreamResponse fileResponse)
{
if (!System.IO.File.Exists(fileResponse.FilePath))
return Results.NotFound();
var stream = System.IO.File.Open(fileResponse.FilePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
httpContext.Response.Headers.ContentDisposition = $"inline; filename=\"{Uri.EscapeDataString(fileResponse.FileName)}\"";
httpContext.Response.Headers.AcceptRanges = "bytes";
httpContext.Response.Headers.CacheControl = "public, max-age=3600";
return Results.File(
stream,
contentType: fileResponse.ContentType,
lastModified: fileResponse.LastModified,
enableRangeProcessing: true);
}
return result is not null ? Results.Json(result) : Results.Ok();
};
}
/// <summary>
/// 从 ASP.NET Core 的 HttpContext 构建统一的 ServiceEndpointContext
/// 提取路径、方法、请求头、查询参数和请求体。
/// </summary>
/// <param name="httpContext">ASP.NET Core 的 HttpContext。</param>
/// <returns>构建好的统一端点上下文。</returns>
private static async Task<ServiceEndpointContext> BuildContextFromHttpContext(HttpContext httpContext)
{
var ctx = new ServiceEndpointContext
{
Path = httpContext.Request.Path.Value ?? "/",
Method = httpContext.Request.Method,
StatusCode = 200,
};
foreach (var header in httpContext.Request.Headers)
{
ctx.Headers[header.Key] = header.Value.ToString();
}
foreach (var query in httpContext.Request.Query)
{
ctx.Query[query.Key] = query.Value.ToString();
}
foreach (var routeValue in httpContext.Request.RouteValues)
{
if (routeValue.Value is not null)
{
ctx.RouteValues[routeValue.Key] = routeValue.Value.ToString() ?? string.Empty;
}
}
if (httpContext.Request.ContentLength > 0)
{
using var reader = new StreamReader(httpContext.Request.Body);
ctx.Body = await reader.ReadToEndAsync();
}
ctx.Items["HttpContext"] = httpContext;
ctx.Items["User"] = httpContext.User;
return ctx;
}
/// <summary>
/// 将统一过滤器转换为 ASP.NET Core 端点过滤器,
/// 在调用统一过滤器前后桥接上下文和状态。
/// </summary>
/// <param name="unifiedFilter">统一过滤器。</param>
/// <param name="aspContext">ASP.NET Core 过滤器调用上下文。</param>
/// <param name="aspNext">ASP.NET Core 过滤器管道中的下一个委托。</param>
/// <returns>过滤器执行结果,可能包含短路响应体。</returns>
private static async ValueTask<object?> ConvertFilterAsync(
UnifiedFilter unifiedFilter,
AspNetCoreFilterContext aspContext,
AspNetCoreFilterDelegate aspNext)
{
var httpContext = aspContext.HttpContext;
var ctx = httpContext.Items["UnifiedContext"] as ServiceEndpointContext
?? await BuildContextFromHttpContext(httpContext);
httpContext.Items["UnifiedContext"] = ctx;
object? nextResult = null;
await unifiedFilter.InvokeAsync(ctx, async (c) =>
{
httpContext.Response.StatusCode = c.StatusCode;
foreach (var kvp in c.ResponseHeaders)
{
httpContext.Response.Headers[kvp.Key] = kvp.Value;
}
nextResult = await aspNext(aspContext);
});
if (ctx.ResponseBody is not null)
{
return Results.Json(ctx.ResponseBody, statusCode: ctx.StatusCode);
}
return nextResult;
}
}
}
+47
View File
@@ -0,0 +1,47 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>FileShare_API</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.5" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.5" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="8.1.4" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\FileShare-Services\FileShare-Services.csproj" />
<ProjectReference Include="..\FileShare-Common\FileShare-Common.csproj" />
<ProjectReference Include="..\FileShare-EFCore\FileShare-EFCore.csproj" />
</ItemGroup>
<ItemGroup>
<Folder Include="Controllers\" />
</ItemGroup>
<Target Name="RestoreFrontendPackages" BeforeTargets="Build" Condition="'$(SkipFrontendBuild)' != 'true' And Exists('..\FileShare-Web-VUE\package.json') And !Exists('..\FileShare-Web-VUE\node_modules')">
<Message Importance="high" Text="Restoring FileShare-Web-VUE npm packages..." />
<Exec WorkingDirectory="..\FileShare-Web-VUE" Command="npm.cmd install" />
</Target>
<Target Name="BuildFrontend" BeforeTargets="Build" DependsOnTargets="RestoreFrontendPackages" Condition="'$(SkipFrontendBuild)' != 'true' And Exists('..\FileShare-Web-VUE\package.json')">
<Message Importance="high" Text="Building FileShare-Web-VUE into FileShare-API/wwwroot..." />
<Exec WorkingDirectory="..\FileShare-Web-VUE" Command="npm.cmd run build-only" />
<RemoveDir Directories="wwwroot" />
<MakeDir Directories="wwwroot" />
<ItemGroup>
<FrontendDist Include="..\FileShare-Web-VUE\dist\**\*.*" />
</ItemGroup>
<Copy SourceFiles="@(FrontendDist)" DestinationFiles="@(FrontendDist->'wwwroot\%(RecursiveDir)%(Filename)%(Extension)')" SkipUnchangedFiles="false" />
</Target>
</Project>
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="Current" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup>
<ActiveDebugProfile>http</ActiveDebugProfile>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
<DebuggerFlavor>ProjectDebugger</DebuggerFlavor>
</PropertyGroup>
</Project>
+6
View File
@@ -0,0 +1,6 @@
@FileShare_API_HostAddress = http://localhost:5206
GET {{FileShare_API_HostAddress}}/weatherforecast/
Accept: application/json
###
+75
View File
@@ -0,0 +1,75 @@
using FileShare_API.Configuration;
using FileShare_API.Extensions;
using FileShare_Common.Infrastructure;
using FileShare_EFCore.Database;
using FileShare_Services.Core;
using Serilog;
// 初始化日志系统
Log.Logger = LoggingConfiguration.CreateDefaultLogger(logDir: "logs");
Log.Information("FileShare-API 正在启动...");
try
{
var builder = WebApplication.CreateBuilder(args);
// 配置 Kestrel 监听所有本机 IP
builder.WebHost.UseUrls("http://0.0.0.0:5206", "https://0.0.0.0:7165");
// 使用 Serilog 作为日志提供程序
builder.Host.UseSerilog();
// Add services to the container.
builder.Services.AddControllers();
builder.Services.AddOpenApi();
builder.Services.AddCors(options =>
{
options.AddPolicy("LanFileViewer", policy =>
policy.AllowAnyOrigin()
.AllowAnyHeader()
.AllowAnyMethod());
});
// 注册统一端点及业务服务(入口在 FileShare-Services/Endpoints/AppEndpoints.cs
builder.Services.AddUnifiedApiServices(builder.Configuration);
var app = builder.Build();
// 初始化数据库(自动迁移 + 种子数据)
app.Services.InitializeDatabase<AppDataContext>();
var endpoints = app.Services.GetRequiredService<ServiceEndpointCollection>();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.UseSwaggerUI(options =>
{
options.SwaggerEndpoint("/openapi/v1.json", "FileShare API v1");
options.RoutePrefix = "swagger";
});
}
app.UseDefaultFiles();
app.UseStaticFiles();
// 局域网文件播放优先使用 HTTP,避免手机浏览器对自签 HTTPS/HTTP2 视频流的兼容问题。
app.UseCors("LanFileViewer");
app.UseAuthentication();
app.UseAuthorization();
// 将统一端点映射到 ASP.NET Core 路由
app.MapUnifiedEndpoints(endpoints, app.Services);
app.MapFileStreamEndpoints();
app.MapFallbackToFile("index.html");
app.Run();
}
catch (Exception ex)
{
Log.Fatal(ex, "FileShare-API 启动失败");
}
finally
{
Log.CloseAndFlush();
}
@@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://0.0.0.0:5206",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://0.0.0.0:7165;http://0.0.0.0:5206",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
@@ -0,0 +1,38 @@
using FileShare_Services.Services.FileLibrary;
namespace FileShare_API.Services
{
public sealed class FileLibraryScanHostedService(IServiceScopeFactory scopeFactory, ILogger<FileLibraryScanHostedService> logger)
: BackgroundService
{
private static readonly TimeSpan Interval = TimeSpan.FromMinutes(1);
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await ScanAsync(stoppingToken);
using var timer = new PeriodicTimer(Interval);
while (await timer.WaitForNextTickAsync(stoppingToken))
{
await ScanAsync(stoppingToken);
}
}
private async Task ScanAsync(CancellationToken cancellationToken)
{
try
{
await using var scope = scopeFactory.CreateAsyncScope();
var scanner = scope.ServiceProvider.GetRequiredService<IFileLibraryService>();
await scanner.ScanDueRootsAsync(cancellationToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
}
catch (Exception ex)
{
logger.LogWarning(ex, "文件库定时扫描失败。");
}
}
}
}
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
+24
View File
@@ -0,0 +1,24 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"Jwt": {
"Issuer": "FileShare-API",
"Audience": "FileShare-Client",
"SigningKey": "change-this-development-signing-key-at-least-32-bytes",
"AccessTokenMinutes": 60,
"RefreshTokenDays": 30
},
"DatabaseConfiguration": {
"Provider": "SQLite",
"ConnectionString": "Data Source=app.db",
"AutoMigrate": true,
"RecreateDatabase": false,
"EnableDetailedLog": false,
"Timeout": 30
}
}