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,70 @@
using System;
using System.Linq;
namespace FileShare_Services.Core
{
/// <summary>
/// 端点列表打印工具 —— 在应用启动时输出所有已注册的拦截接口。
/// 类似 Swagger 的接口清单效果。
/// </summary>
public static class EndpointPrinter
{
/// <summary>
/// 打印所有已注册端点到控制台。
/// </summary>
public static void PrintEndpoints(
ServiceEndpointCollection collection,
string? title = null,
EndpointHostTarget host = EndpointHostTarget.All)
{
title ??= "API Endpoints";
var endpoints = collection.ForHost(host).ToList();
var maxMethodLen = endpoints.Count > 0
? endpoints.Max(e => e.HttpMethod.Length)
: 4;
var maxPathLen = endpoints.Count > 0
? endpoints.Max(e => e.Pattern.Length)
: 8;
var totalWidth = maxMethodLen + maxPathLen + 5;
var separator = new string('─', Math.Max(totalWidth, 50));
Console.WriteLine();
Console.WriteLine($"╔═ {title} ═{new string('═', Math.Max(0, totalWidth - title.Length - 3))}╗");
Console.WriteLine($"║ {"Method".PadRight(maxMethodLen)} │ {"Path".PadRight(maxPathLen)} │ Auth ║");
Console.WriteLine($"╟{separator}╢");
foreach (var ep in endpoints.OrderBy(e => e.Pattern))
{
var auth = ep.RequireAuthorization
? (ep.Roles.Count > 0 ? string.Join(",", ep.Roles) : ep.Policy ?? "✓")
: "—";
var methodColor = ep.HttpMethod switch
{
"GET" => ConsoleColor.Green,
"POST" => ConsoleColor.Blue,
"PUT" => ConsoleColor.Yellow,
"DELETE" => ConsoleColor.Red,
_ => ConsoleColor.Gray,
};
var savedColor = Console.ForegroundColor;
Console.Write("║ ");
Console.ForegroundColor = methodColor;
Console.Write(ep.HttpMethod.PadRight(maxMethodLen));
Console.ForegroundColor = savedColor;
Console.Write(" │ ");
Console.Write(ep.Pattern.PadRight(maxPathLen));
Console.Write(" │ ");
Console.Write(auth.PadRight(4));
Console.WriteLine(" ║");
}
Console.WriteLine($"╚{separator}╝");
Console.WriteLine($" Total: {endpoints.Count} endpoint(s)");
Console.WriteLine();
}
}
}
@@ -0,0 +1,11 @@
namespace FileShare_Services.Core
{
/// <summary>
/// 文件流响应 —— 管道检测到此类型时将返回原始文件而非 JSON。
/// </summary>
public sealed record FileStreamResponse(
string FilePath,
string FileName,
string ContentType,
DateTime LastModified);
}
@@ -0,0 +1,107 @@
using FileShare_Common.Core;
using System;
using System.Threading.Tasks;
namespace FileShare_Services.Core
{
/// <summary>
/// 全局异常拦截过滤器 —— 自动包裹所有端点处理器,无需在每个方法中写 try-catch。
/// 所有未捕获异常会被转为统一的 ApiResponse 错误格式。
/// </summary>
public sealed class GlobalExceptionFilter : IEndpointFilter
{
/// <summary>
/// 是否在错误响应中包含异常详情。
/// </summary>
private readonly bool _includeDetails;
/// <summary>
/// 初始化全局异常过滤器。
/// </summary>
/// <param name="includeDetails">是否在响应中包含异常详情(开发环境建议 true,生产环境 false</param>
public GlobalExceptionFilter(bool includeDetails = false)
{
_includeDetails = includeDetails;
}
/// <summary>
/// 执行过滤器逻辑:包裹下一个委托,捕获所有未处理异常并转换为统一错误响应。
/// </summary>
/// <param name="context">请求上下文。</param>
/// <param name="next">管道中的下一个委托。</param>
public async Task InvokeAsync(ServiceEndpointContext context, EndpointFilterDelegate next)
{
try
{
await next(context);
}
catch (OperationCanceledException)
{
// 取消操作不视为错误
context.StatusCode = 499;
context.StatusMessage = "Client Closed Request";
context.ResponseBody = ApiResponse<object>.Fail(499, "请求已取消");
}
catch (UnauthorizedAccessException ex)
{
context.StatusCode = 401;
context.StatusMessage = "Unauthorized";
context.ResponseBody = ApiResponse<object>.Unauthorized(
_includeDetails ? ex.Message : "未授权访问");
}
catch (InvalidOperationException ex) when (ex.Message.Contains("not found", StringComparison.OrdinalIgnoreCase))
{
context.StatusCode = 404;
context.StatusMessage = "Not Found";
context.ResponseBody = ApiResponse<object>.NotFound(
_includeDetails ? ex.Message : "资源不存在");
}
catch (ArgumentException ex)
{
context.StatusCode = 400;
context.StatusMessage = "Bad Request";
context.ResponseBody = ApiResponse<object>.BadRequest(
_includeDetails ? ex.Message : "参数错误");
}
catch (Exception ex)
{
// 记录完整日志(无论是否返回详情)
LogException(context, ex);
context.StatusCode = 500;
context.StatusMessage = "Internal Server Error";
context.ResponseBody = ApiResponse<object>.ServerError(
_includeDetails ? ex.Message : "服务器内部错误,请联系管理员");
// 可选:在开发环境附加堆栈信息
if (_includeDetails)
{
// 通过 Items 传递额外调试信息
context.Items["ExceptionDetail"] = ex.ToString();
}
}
}
/// <summary>
/// 记录异常日志,优先使用 Serilog,不可用时回退到 Console。
/// </summary>
/// <param name="context">请求上下文。</param>
/// <param name="ex">异常对象。</param>
private static void LogException(ServiceEndpointContext context, Exception ex)
{
try
{
// 使用 Serilog(如果已配置)
Serilog.Log.Error(ex,
"全局异常拦截 | {Method} {Path} | {ExceptionType}: {Message}",
context.Method, context.Path, ex.GetType().Name, ex.Message);
}
catch
{
// Serilog 不可用时回退到 Console
Console.Error.WriteLine(
$"[ERROR] {context.Method} {context.Path} | {ex.GetType().Name}: {ex.Message}");
}
}
}
}
+39
View File
@@ -0,0 +1,39 @@
using System.Security.Claims;
namespace FileShare_Services.Core
{
/// <summary>
/// 鉴权服务抽象 —— 各宿主按自己的方式实现(JWT / Cookie / Token 等)。
/// </summary>
public interface IAuthService
{
/// <summary>
/// 验证请求并返回用户主体;返回 null 表示未授权。
/// </summary>
Task<ClaimsPrincipal?> AuthenticateAsync(ServiceEndpointContext context);
/// <summary>
/// 检查当前用户是否有指定权限。
/// </summary>
Task<bool> AuthorizeAsync(ClaimsPrincipal user, string policy);
}
/// <summary>
/// 无需鉴权的默认实现(开发/公开 API 场景)。
/// </summary>
public sealed class AnonymousAuthService : IAuthService
{
/// <inheritdoc />
public Task<ClaimsPrincipal?> AuthenticateAsync(ServiceEndpointContext context)
{
// 匿名用户,始终通过
var identity = new ClaimsIdentity("anonymous");
return Task.FromResult<ClaimsPrincipal?>(new ClaimsPrincipal(identity));
}
/// <inheritdoc />
public Task<bool> AuthorizeAsync(ClaimsPrincipal user, string policy)
{
return Task.FromResult(true);
}
}
}
@@ -0,0 +1,48 @@
using System.Threading.Tasks;
namespace FileShare_Services.Core
{
/// <summary>
/// 端点过滤器抽象 —— 在请求处理前后执行逻辑。
/// 类似于 ASP.NET Core 的 IEndpointFilter,但可在任何宿主中使用。
/// </summary>
public interface IEndpointFilter
{
/// <summary>
/// 过滤器执行方法。
/// 调用 next(ctx) 继续管道;不调用则短路。
/// </summary>
Task InvokeAsync(ServiceEndpointContext context, EndpointFilterDelegate next);
}
/// <summary>
/// 过滤器管道中的下一个委托。
/// </summary>
public delegate Task EndpointFilterDelegate(ServiceEndpointContext context);
/// <summary>
/// 用于包装匿名过滤器的简单实现。
/// </summary>
internal sealed class AnonymousEndpointFilter : IEndpointFilter
{
/// <summary>
/// 匿名过滤器的委托实现。
/// </summary>
private readonly Func<ServiceEndpointContext, EndpointFilterDelegate, Task> _filter;
/// <summary>
/// 使用匿名函数创建过滤器。
/// </summary>
/// <param name="filter">过滤器委托。</param>
public AnonymousEndpointFilter(Func<ServiceEndpointContext, EndpointFilterDelegate, Task> filter)
{
_filter = filter;
}
/// <inheritdoc />
public Task InvokeAsync(ServiceEndpointContext context, EndpointFilterDelegate next)
{
return _filter(context, next);
}
}
}
@@ -0,0 +1,532 @@
using FileShare_Common.Core;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
namespace FileShare_Services.Core
{
/// <summary>
/// 端点挂载的宿主目标。
/// </summary>
[Flags]
public enum EndpointHostTarget
{
/// <summary>挂载到 FileShare-APIASP.NET Core Web API)。</summary>
Api = 1,
/// <summary>挂载到 FileShare-PC(桌面 WebView)。</summary>
Pc = 2,
/// <summary>同时挂载到 API 和 PC。</summary>
All = Api | Pc,
}
/// <summary>
/// 单个端点定义。
/// </summary>
public class ServiceEndpoint
{
/// <summary>路由路径,如 "api/wData"</summary>
public string Pattern { get; init; } = string.Empty;
/// <summary>HTTP 方法(GET/POST/PUT/DELETE</summary>
public string HttpMethod { get; init; } = "GET";
/// <summary>端点名称(用于 OpenAPI / 日志)</summary>
public string? Name { get; set; }
/// <summary>OpenAPI 分组标签。</summary>
public string? OpenApiTag { get; set; }
/// <summary>OpenAPI 摘要。</summary>
public string? OpenApiSummary { get; set; }
/// <summary>OpenAPI 描述。</summary>
public string? OpenApiDescription { get; set; }
/// <summary>OpenAPI 请求体类型。</summary>
public Type? OpenApiRequestType { get; set; }
/// <summary>OpenAPI 200 响应数据类型。</summary>
public Type? OpenApiResponseType { get; set; }
/// <summary>端点处理器</summary>
public Func<ServiceEndpointContext, Task<object?>> Handler { get; init; } = _ => Task.FromResult<object?>(null);
/// <summary>该端点专属的过滤器(按顺序执行)</summary>
public List<IEndpointFilter> Filters { get; init; } = new();
/// <summary>是否需要鉴权</summary>
public bool RequireAuthorization { get; set; }
/// <summary>鉴权策略名</summary>
public string? Policy { get; set; }
/// <summary>允许访问该端点的角色。多个角色满足任意一个即可。</summary>
public List<string> Roles { get; } = new();
/// <summary>端点挂载的宿主。默认 API 和 PC 都挂载。</summary>
public EndpointHostTarget HostTarget { get; set; } = EndpointHostTarget.All;
/// <summary>
/// 设置端点名称(Fluent API)。
/// </summary>
public ServiceEndpoint WithName(string name)
{
Name = name;
return this;
}
/// <summary>
/// 设置端点的 OpenAPI 元数据(标签、摘要、描述、请求/响应类型)。
/// </summary>
/// <param name="tag">OpenAPI 分组标签。</param>
/// <param name="summary">简要摘要。</param>
/// <param name="description">详细描述。</param>
/// <param name="requestType">请求体类型。</param>
/// <param name="responseType">成功响应类型。</param>
/// <returns>当前端点实例(Fluent API)。</returns>
public ServiceEndpoint WithOpenApi(
string tag,
string summary,
string? description = null,
Type? requestType = null,
Type? responseType = null)
{
OpenApiTag = tag;
OpenApiSummary = summary;
OpenApiDescription = description;
OpenApiRequestType = requestType;
OpenApiResponseType = responseType;
return this;
}
/// <summary>
/// 标记端点需要登录。
/// </summary>
public ServiceEndpoint RequireAuth()
{
RequireAuthorization = true;
return this;
}
/// <summary>
/// 标记端点需要指定角色。多个角色满足任意一个即可。
/// </summary>
public ServiceEndpoint RequireRoles(params string[] roles)
{
RequireAuthorization = true;
Roles.Clear();
Roles.AddRange(roles.Where(role => !string.IsNullOrWhiteSpace(role)).Select(role => role.Trim()));
return this;
}
/// <summary>
/// 只挂载到 FileShare-API。
/// </summary>
public ServiceEndpoint ApiOnly()
{
HostTarget = EndpointHostTarget.Api;
return this;
}
/// <summary>
/// 只挂载到 FileShare-PC。
/// </summary>
public ServiceEndpoint PcOnly()
{
HostTarget = EndpointHostTarget.Pc;
return this;
}
/// <summary>
/// 判断端点是否支持指定的宿主目标。
/// </summary>
/// <param name="host">要检查的宿主目标。</param>
/// <returns>是否支持。</returns>
public bool SupportsHost(EndpointHostTarget host)
{
return (HostTarget & host) != 0;
}
}
/// <summary>
/// 端点集合 —— 所有端点的注册中心。在 FileShare-Services 中统一配置。
/// </summary>
public class ServiceEndpointCollection
{
/// <summary>所有已注册的端点</summary>
public List<ServiceEndpoint> Endpoints { get; } = new();
/// <summary>
/// 获取指定宿主目标的所有端点。
/// </summary>
/// <param name="host">宿主目标。</param>
/// <returns>匹配的端点集合。</returns>
public IEnumerable<ServiceEndpoint> ForHost(EndpointHostTarget host)
{
return Endpoints.Where(endpoint => endpoint.SupportsHost(host));
}
/// <summary>作用于所有端点的全局过滤器</summary>
public List<IEndpointFilter> GlobalFilters { get; } = new();
/// <summary>
/// 注册一个端点。
/// </summary>
public ServiceEndpoint MapGet(string pattern, Func<ServiceEndpointContext, Task<object?>> handler)
{
return AddEndpoint(pattern, "GET", handler);
}
/// <summary>
/// 注册一个返回统一响应契约的 GET 端点。
/// </summary>
public ServiceEndpoint MapGet(string pattern, Func<ServiceEndpointContext, Task<IApiResponse>> handler)
{
return MapGet(pattern, CreateApiResponseHandler(handler));
}
/// <summary>
/// 注册一个带服务依赖注入的 GET 端点。
/// </summary>
/// <typeparam name="TService">服务类型。</typeparam>
/// <param name="pattern">路由路径。</param>
/// <param name="handler">接受服务实例和上下文的处理器。</param>
/// <returns>已注册的端点实例。</returns>
public ServiceEndpoint MapGet<TService>(
string pattern,
Func<TService, ServiceEndpointContext, Task<object?>> handler)
where TService : notnull
{
return MapGet(pattern, CreateServiceHandler(handler));
}
/// <summary>
/// 注册一个带服务依赖注入且返回统一响应契约的 GET 端点。
/// </summary>
public ServiceEndpoint MapGet<TService>(
string pattern,
Func<TService, ServiceEndpointContext, Task<IApiResponse>> handler)
where TService : notnull
{
return MapGet(pattern, CreateServiceHandler(handler));
}
/// <summary>
/// 注册一个带查询请求 DTO 和服务依赖注入的 GET 端点。
/// </summary>
public ServiceEndpoint MapGet<TService, TRequest>(
string pattern,
Func<TService, TRequest, ServiceEndpointContext, Task<IApiResponse>> handler)
where TService : notnull
{
var endpoint = MapGet(
pattern,
CreateServiceHandler<TService>((service, ctx) =>
handler(service, ServiceRequestBinder.BindQuery<TRequest>(ctx), ctx)));
endpoint.OpenApiRequestType ??= typeof(TRequest);
return endpoint;
}
/// <summary>
/// 注册一个 POST 端点。
/// </summary>
public ServiceEndpoint MapPost(string pattern, Func<ServiceEndpointContext, Task<object?>> handler)
{
return AddEndpoint(pattern, "POST", handler);
}
/// <summary>
/// 注册一个返回统一响应契约的 POST 端点。
/// </summary>
public ServiceEndpoint MapPost(string pattern, Func<ServiceEndpointContext, Task<IApiResponse>> handler)
{
return MapPost(pattern, CreateApiResponseHandler(handler));
}
/// <summary>
/// 注册一个带服务依赖注入的 POST 端点。
/// </summary>
/// <typeparam name="TService">服务类型。</typeparam>
/// <param name="pattern">路由路径。</param>
/// <param name="handler">接受服务实例和上下文的处理器。</param>
/// <returns>已注册的端点实例。</returns>
public ServiceEndpoint MapPost<TService>(
string pattern,
Func<TService, ServiceEndpointContext, Task<object?>> handler)
where TService : notnull
{
return MapPost(pattern, CreateServiceHandler(handler));
}
/// <summary>
/// 注册一个带服务依赖注入且返回统一响应契约的 POST 端点。
/// </summary>
public ServiceEndpoint MapPost<TService>(
string pattern,
Func<TService, ServiceEndpointContext, Task<IApiResponse>> handler)
where TService : notnull
{
return MapPost(pattern, CreateServiceHandler(handler));
}
/// <summary>
/// 注册一个带 JSON 请求 DTO 和服务依赖注入的 POST 端点。
/// </summary>
public ServiceEndpoint MapPost<TService, TRequest>(
string pattern,
Func<TService, TRequest, ServiceEndpointContext, Task<IApiResponse>> handler)
where TService : notnull
{
var endpoint = MapPost(
pattern,
CreateServiceHandler<TService>((service, ctx) =>
handler(service, ServiceRequestBinder.BindBody<TRequest>(ctx), ctx)));
endpoint.OpenApiRequestType ??= typeof(TRequest);
return endpoint;
}
/// <summary>
/// 注册一个 PUT 端点。
/// </summary>
public ServiceEndpoint MapPut(string pattern, Func<ServiceEndpointContext, Task<object?>> handler)
{
return AddEndpoint(pattern, "PUT", handler);
}
/// <summary>
/// 注册一个返回统一响应契约的 PUT 端点。
/// </summary>
public ServiceEndpoint MapPut(string pattern, Func<ServiceEndpointContext, Task<IApiResponse>> handler)
{
return MapPut(pattern, CreateApiResponseHandler(handler));
}
/// <summary>
/// 注册一个带服务依赖注入的 PUT 端点。
/// </summary>
/// <typeparam name="TService">服务类型。</typeparam>
/// <param name="pattern">路由路径。</param>
/// <param name="handler">接受服务实例和上下文的处理器。</param>
/// <returns>已注册的端点实例。</returns>
public ServiceEndpoint MapPut<TService>(
string pattern,
Func<TService, ServiceEndpointContext, Task<object?>> handler)
where TService : notnull
{
return MapPut(pattern, CreateServiceHandler(handler));
}
/// <summary>
/// 注册一个带服务依赖注入且返回统一响应契约的 PUT 端点。
/// </summary>
public ServiceEndpoint MapPut<TService>(
string pattern,
Func<TService, ServiceEndpointContext, Task<IApiResponse>> handler)
where TService : notnull
{
return MapPut(pattern, CreateServiceHandler(handler));
}
/// <summary>
/// 注册一个带 JSON 请求 DTO 和服务依赖注入的 PUT 端点。
/// </summary>
public ServiceEndpoint MapPut<TService, TRequest>(
string pattern,
Func<TService, TRequest, ServiceEndpointContext, Task<IApiResponse>> handler)
where TService : notnull
{
var endpoint = MapPut(
pattern,
CreateServiceHandler<TService>((service, ctx) =>
handler(service, ServiceRequestBinder.BindBody<TRequest>(ctx), ctx)));
endpoint.OpenApiRequestType ??= typeof(TRequest);
return endpoint;
}
/// <summary>
/// 注册一个 DELETE 端点。
/// </summary>
public ServiceEndpoint MapDelete(string pattern, Func<ServiceEndpointContext, Task<object?>> handler)
{
return AddEndpoint(pattern, "DELETE", handler);
}
/// <summary>
/// 注册一个返回统一响应契约的 DELETE 端点。
/// </summary>
public ServiceEndpoint MapDelete(string pattern, Func<ServiceEndpointContext, Task<IApiResponse>> handler)
{
return MapDelete(pattern, CreateApiResponseHandler(handler));
}
/// <summary>
/// 注册一个带服务依赖注入的 DELETE 端点。
/// </summary>
/// <typeparam name="TService">服务类型。</typeparam>
/// <param name="pattern">路由路径。</param>
/// <param name="handler">接受服务实例和上下文的处理器。</param>
/// <returns>已注册的端点实例。</returns>
public ServiceEndpoint MapDelete<TService>(
string pattern,
Func<TService, ServiceEndpointContext, Task<object?>> handler)
where TService : notnull
{
return MapDelete(pattern, CreateServiceHandler(handler));
}
/// <summary>
/// 注册一个带服务依赖注入且返回统一响应契约的 DELETE 端点。
/// </summary>
public ServiceEndpoint MapDelete<TService>(
string pattern,
Func<TService, ServiceEndpointContext, Task<IApiResponse>> handler)
where TService : notnull
{
return MapDelete(pattern, CreateServiceHandler(handler));
}
/// <summary>
/// 注册一个带查询请求 DTO 和服务依赖注入的 DELETE 端点。
/// </summary>
public ServiceEndpoint MapDelete<TService, TRequest>(
string pattern,
Func<TService, TRequest, ServiceEndpointContext, Task<IApiResponse>> handler)
where TService : notnull
{
var endpoint = MapDelete(
pattern,
CreateServiceHandler<TService>((service, ctx) =>
handler(service, ServiceRequestBinder.BindQuery<TRequest>(ctx), ctx)));
endpoint.OpenApiRequestType ??= typeof(TRequest);
return endpoint;
}
/// <summary>
/// 添加全局过滤器(作用于所有端点)。
/// </summary>
public ServiceEndpointCollection AddGlobalFilter(IEndpointFilter filter)
{
GlobalFilters.Add(filter);
return this;
}
/// <summary>
/// 通过匿名函数添加全局过滤器。
/// </summary>
public ServiceEndpointCollection AddGlobalFilter(Func<ServiceEndpointContext, EndpointFilterDelegate, Task> filter)
{
GlobalFilters.Add(new AnonymousEndpointFilter(filter));
return this;
}
/// <summary>
/// 内部方法,创建端点并添加到集合。
/// </summary>
/// <param name="pattern">路由路径。</param>
/// <param name="method">HTTP 方法。</param>
/// <param name="handler">端点处理器。</param>
/// <returns>已创建的端点实例。</returns>
private ServiceEndpoint AddEndpoint(string pattern, string method, Func<ServiceEndpointContext, Task<object?>> handler)
{
var endpoint = new ServiceEndpoint
{
Pattern = pattern,
HttpMethod = method,
Handler = handler,
};
Endpoints.Add(endpoint);
return endpoint;
}
/// <summary>
/// 创建自动从 DI 解析服务实例并调用处理器的委托包装。
/// </summary>
/// <typeparam name="TService">服务类型。</typeparam>
/// <param name="handler">接受服务实例和上下文的处理器。</param>
/// <returns>包装后的处理器委托。</returns>
private static Func<ServiceEndpointContext, Task<object?>> CreateServiceHandler<TService>(
Func<TService, ServiceEndpointContext, Task<object?>> handler)
where TService : notnull
{
return async ctx =>
{
var serviceProvider = ctx.Items["ServiceProvider"] as IServiceProvider
?? throw new InvalidOperationException("ServiceProvider 未注入。");
await using var scope = serviceProvider.CreateAsyncScope();
var service = scope.ServiceProvider.GetRequiredService<TService>();
return await handler(service, ctx);
};
}
/// <summary>
/// 将统一响应契约适配为端点集合内部使用的异构响应类型。
/// </summary>
private static Func<ServiceEndpointContext, Task<object?>> CreateApiResponseHandler(
Func<ServiceEndpointContext, Task<IApiResponse>> handler)
{
return async ctx => await handler(ctx);
}
/// <summary>
/// 为服务端点创建统一响应契约的 DI 包装。
/// </summary>
private static Func<ServiceEndpointContext, Task<IApiResponse>> CreateServiceHandler<TService>(
Func<TService, ServiceEndpointContext, Task<IApiResponse>> handler)
where TService : notnull
{
return async ctx =>
{
var serviceProvider = ctx.Items["ServiceProvider"] as IServiceProvider
?? throw new InvalidOperationException("ServiceProvider 未注入。");
await using var scope = serviceProvider.CreateAsyncScope();
var service = scope.ServiceProvider.GetRequiredService<TService>();
return await handler(service, ctx);
};
}
}
/// <summary>
/// 构建器 —— 提供 Fluent API 来配置所有端点。
/// </summary>
public class ServiceEndpointBuilder
{
/// <summary>
/// 端点集合
/// </summary>
public ServiceEndpointCollection Endpoints { get; } = new();
/// <summary>
/// 鉴权服务(默认匿名)
/// </summary>
public IAuthService AuthService { get; set; } = new AnonymousAuthService();
/// <summary>
/// 配置端点(在此方法中调用 endpoints.MapGet 等)。
/// </summary>
public ServiceEndpointBuilder ConfigureEndpoints(Action<ServiceEndpointCollection> configure)
{
configure(Endpoints);
return this;
}
/// <summary>
/// 设置鉴权服务。
/// </summary>
public ServiceEndpointBuilder UseAuthService(IAuthService authService)
{
AuthService = authService;
return this;
}
/// <summary>
/// 构建最终的端点集合。
/// </summary>
public ServiceEndpointCollection Build()
{
return Endpoints;
}
}
}
@@ -0,0 +1,84 @@
using System.Collections.Generic;
namespace FileShare_Services.Core
{
/// <summary>
/// 抽象的请求上下文,屏蔽不同宿主(ASP.NET Core / Desktop WebView)的差异。
/// </summary>
public class ServiceEndpointContext
{
/// <summary>
/// 请求路径,例如 "api/wData"
/// </summary>
public string Path { get; init; } = string.Empty;
/// <summary>
/// HTTP 方法(GET, POST, PUT, DELETE 等)
/// </summary>
public string Method { get; init; } = "GET";
/// <summary>
/// 请求头
/// </summary>
public Dictionary<string, string> Headers { get; init; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// 请求体(原始字符串)
/// </summary>
public string? Body { get; set; }
/// <summary>
/// 查询参数
/// </summary>
public Dictionary<string, string> Query { get; init; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// 路由路径参数。
/// </summary>
public Dictionary<string, string> RouteValues { get; init; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// 响应状态码
/// </summary>
public int StatusCode { get; set; } = 200;
/// <summary>
/// 响应状态描述
/// </summary>
public string StatusMessage { get; set; } = "OK";
/// <summary>
/// 响应头
/// </summary>
public Dictionary<string, string> ResponseHeaders { get; set; } = new(StringComparer.OrdinalIgnoreCase)
{
["Content-Type"] = "application/json; charset=utf-8"
};
/// <summary>
/// 响应体
/// </summary>
public object? ResponseBody { get; set; }
/// <summary>
/// 存储在请求生命周期中的任意数据(由中间件/过滤器使用)
/// </summary>
public Dictionary<string, object?> Items { get; init; } = new();
/// <summary>
/// 获取请求头值
/// </summary>
public string? GetHeader(string key)
{
return Headers.TryGetValue(key, out var value) ? value : null;
}
/// <summary>
/// 设置响应头
/// </summary>
public void SetResponseHeader(string key, string value)
{
ResponseHeaders[key] = value;
}
}
}
@@ -0,0 +1,75 @@
namespace FileShare_Services.Core
{
/// <summary>
/// Matches unified endpoint patterns and extracts simple route values.
/// </summary>
internal static class ServiceEndpointPatternMatcher
{
/// <summary>
/// Match literal segments and single-segment route parameters such as {id} or {id:int}.
/// </summary>
public static bool TryMatch(
string pattern,
string path,
out Dictionary<string, string> routeValues)
{
routeValues = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var patternSegments = SplitSegments(pattern);
var pathSegments = SplitSegments(path);
if (patternSegments.Length != pathSegments.Length)
{
return false;
}
for (var index = 0; index < patternSegments.Length; index++)
{
var patternSegment = patternSegments[index];
var pathSegment = pathSegments[index];
if (TryGetParameterName(patternSegment, out var parameterName))
{
if (!MatchesConstraint(patternSegment, pathSegment))
{
return false;
}
routeValues[parameterName] = Uri.UnescapeDataString(pathSegment);
continue;
}
if (!string.Equals(patternSegment, pathSegment, StringComparison.OrdinalIgnoreCase))
{
return false;
}
}
return true;
}
private static string[] SplitSegments(string value)
{
return value.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}
private static bool TryGetParameterName(string segment, out string parameterName)
{
parameterName = string.Empty;
if (segment.Length < 3 || segment[0] != '{' || segment[^1] != '}')
{
return false;
}
var token = segment[1..^1];
var constraintIndex = token.IndexOf(':');
parameterName = constraintIndex >= 0 ? token[..constraintIndex] : token;
return !string.IsNullOrWhiteSpace(parameterName);
}
private static bool MatchesConstraint(string segment, string value)
{
return !segment.EndsWith(":int}", StringComparison.OrdinalIgnoreCase)
|| int.TryParse(value, out _);
}
}
}
@@ -0,0 +1,53 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace FileShare_Services.Core
{
/// <summary>
/// Binds unified endpoint request models from JSON bodies or query parameters.
/// </summary>
internal static class ServiceRequestBinder
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
NumberHandling = JsonNumberHandling.AllowReadingFromString,
};
/// <summary>
/// Bind a JSON request body. Empty bodies are treated as an empty JSON object.
/// </summary>
public static T BindBody<T>(ServiceEndpointContext context)
{
var json = string.IsNullOrWhiteSpace(context.Body) ? "{}" : context.Body;
return Deserialize<T>(json, "body");
}
/// <summary>
/// Bind route and query parameters to a request DTO.
/// </summary>
public static T BindQuery<T>(ServiceEndpointContext context)
{
var values = new Dictionary<string, string>(context.Query, StringComparer.OrdinalIgnoreCase);
foreach (var routeValue in context.RouteValues)
{
values[routeValue.Key] = routeValue.Value;
}
var json = JsonSerializer.Serialize(values, JsonOptions);
return Deserialize<T>(json, "query");
}
private static T Deserialize<T>(string json, string source)
{
try
{
return JsonSerializer.Deserialize<T>(json, JsonOptions)
?? throw new ArgumentException($"Request {source} cannot be bound to {typeof(T).Name}.");
}
catch (JsonException ex)
{
throw new ArgumentException($"Request {source} cannot be bound to {typeof(T).Name}.", ex);
}
}
}
}
@@ -0,0 +1,115 @@
using FileShare_Common.Core;
using FileShare_Services.Core;
using FileShare_Services.Services.FileLibrary;
using FileShare_Services.Services.QrCode;
namespace FileShare_Services.Endpoints
{
/// <summary>
/// 统一端点配置 —— 所有业务端点在此定义一次。
/// 这是 FileShare-API 和 FileShare-PC 的唯一入口。
/// </summary>
public static class AppEndpoints
{
/// <summary>
/// 配置所有业务端点。调用方传入 builder,按需叠加鉴权、过滤器等。
/// </summary>
/// <param name="builder">端点构建器</param>
/// <param name="includeDetails">是否在错误响应中包含异常详情(开发环境 true</param>
public static ServiceEndpointBuilder Configure(ServiceEndpointBuilder builder, bool includeDetails = false)
{
// ---- 全局异常拦截(自动捕获所有端点中未处理的异常) ----
builder.Endpoints.AddGlobalFilter(new GlobalExceptionFilter(includeDetails));
builder.ConfigureEndpoints(endpoints =>
{
// ---- 全局日志过滤器(记录每个请求) ----
endpoints.AddGlobalFilter(async (ctx, next) =>
{
Serilog.Log.Debug("→ {Method} {Path}", ctx.Method, ctx.Path);
await next(ctx);
Serilog.Log.Debug("← {Method} {Path} | {StatusCode}", ctx.Method, ctx.Path, ctx.StatusCode);
});
// ---- 业务端点注册 ----
endpoints.MapGet<IFileLibraryEndpointService>("api/library/drives", (service, ctx) => service.GetDrivesAsync(ctx))
.WithOpenApi("FileLibrary", "查询服务器磁盘。")
.WithName("GetLibraryDrives");
endpoints.MapGet<IFileLibraryEndpointService, DirectoryQueryRequest>("api/library/directories", (service, request, _) => service.GetDirectoriesAsync(request))
.WithOpenApi("FileLibrary", "查询服务器目录。")
.WithName("GetLibraryDirectories");
endpoints.MapGet<IFileLibraryEndpointService>("api/library/roots", (service, ctx) => service.GetRootsAsync(ctx))
.WithOpenApi("FileLibrary", "查询文件库目录。")
.WithName("GetLibraryRoots");
endpoints.MapPost<IFileLibraryEndpointService, AddLibraryRootRequest>("api/library/roots", (service, request, _) => service.AddRootAsync(request))
.WithOpenApi("FileLibrary", "添加文件库目录。")
.WithName("AddLibraryRoot");
endpoints.MapPost<IFileLibraryEndpointService, UpdateLibraryRootRequest>("api/library/roots/enabled", (service, request, _) => service.SetRootEnabledAsync(request))
.WithOpenApi("FileLibrary", "启用或禁用文件库目录。")
.WithName("SetLibraryRootEnabled");
endpoints.MapPost<IFileLibraryEndpointService, DeleteLibraryRootRequest>("api/library/roots/delete", (service, request, _) => service.DeleteRootAsync(request))
.WithOpenApi("FileLibrary", "删除文件库目录。")
.WithName("DeleteLibraryRoot");
endpoints.MapPost<IFileLibraryEndpointService, ScanLibraryRootRequest>("api/library/roots/scan", (service, request, _) => service.ScanRootAsync(request))
.WithOpenApi("FileLibrary", "立即扫描文件库目录。")
.WithName("ScanLibraryRoot");
endpoints.MapGet<IFileLibraryEndpointService, SearchFilesRequest>("api/files", (service, request, _) => service.SearchFilesAsync(request))
.WithOpenApi("FileLibrary", "分页查询已扫描文件。")
.WithName("SearchFiles");
endpoints.MapGet<IFileLibraryEndpointService, BrowseDirectoryRequest>("api/files/browse", (service, request, _) => service.BrowseDirectoryAsync(request))
.WithOpenApi("FileLibrary", "浏览文件库目录结构。")
.WithName("BrowseDirectory");
endpoints.MapGet<IFileLibraryEndpointService, FileQueryRequest>("api/files/detail", (service, request, _) => service.GetFileAsync(request))
.WithOpenApi("FileLibrary", "查询文件详情。")
.WithName("GetFileDetail");
endpoints.MapGet<IFileLibraryEndpointService, FileQueryRequest>("api/files/text", (service, request, _) => service.GetTextPreviewAsync(request))
.WithOpenApi("FileLibrary", "预览文本文件。")
.WithName("GetTextPreview");
endpoints.MapGet("api/files/stream", GetFileStreamAsync)
.WithOpenApi("FileLibrary", "流式传输文件(支持 Range 请求)。")
.WithName("StreamManagedFile");
endpoints.MapGet<IQrCodeService>("api/qrcode", (service, ctx) => service.GenerateQrCodeAsync(ctx))
.WithOpenApi("Utility", "生成局域网访问二维码。")
.WithName("GetQrCode");
// ---- 需要鉴权的端点示例 ----
// endpoints.MapGet("api/admin/dashboard", AdminDashboardAsync)
// .WithName("AdminDashboard")
// .RequireAuthorization = true
// .Policy = "AdminOnly";
});
return builder;
}
#region
private static async Task<object?> GetFileStreamAsync(ServiceEndpointContext ctx)
{
var sp = ctx.Items["ServiceProvider"] as IServiceProvider;
var service = sp?.GetService(typeof(IFileStreamService)) as IFileStreamService;
if (service is null) return null;
if (!int.TryParse(ctx.Query.GetValueOrDefault("id"), out var id) || id <= 0)
return null;
return await service.GetFileStreamAsync(id);
}
#endregion
}
}
@@ -0,0 +1,61 @@
using FileShare_Services.Core;
using FileShare_Services.Services.AuthService;
namespace FileShare_Services.Endpoints
{
/// <summary>
/// 认证端点统一入口。端点定义在这里,宿主项目只提供对应实现。
/// </summary>
public static class AuthEndpoints
{
/// <summary>
/// 配置 API 端鉴权端点(登录、刷新、登出)。
/// </summary>
/// <param name="builder">端点构建器。</param>
public static void ConfigureApi(ServiceEndpointBuilder builder)
{
builder.ConfigureEndpoints(endpoints =>
{
endpoints.MapPost<IApiAuthEndpointService, ApiLoginRequest>("api/auth/login", (service, request, ctx) => service.LoginAsync(request, ctx))
.WithName("ApiLogin")
.WithOpenApi("Auth", "API 登录,返回 access token 和 refresh token。", "", typeof(ApiLoginRequest), typeof(AuthTokenResponse))
.ApiOnly();
endpoints.MapPost<IApiAuthEndpointService, ApiRefreshTokenRequest>("api/auth/refresh", (service, request, ctx) => service.RefreshAsync(request, ctx))
.WithName("ApiRefresh")
.WithOpenApi("Auth", "API refresh token 轮换。", "", typeof(ApiRefreshTokenRequest), typeof(AuthTokenResponse))
.ApiOnly();
endpoints.MapPost<IApiAuthEndpointService, ApiLogoutRequest>("api/auth/logout", (service, request, ctx) => service.LogoutAsync(request, ctx))
.WithName("ApiLogout")
.WithOpenApi("Auth", "API 退出登录并吊销 refresh token。", "", typeof(ApiLogoutRequest))
.ApiOnly();
});
}
/// <summary>
/// 配置 PC 端鉴权端点(授权码登录、刷新、登出)。
/// </summary>
/// <param name="builder">端点构建器。</param>
public static void ConfigurePc(ServiceEndpointBuilder builder)
{
builder.ConfigureEndpoints(endpoints =>
{
endpoints.MapPost<IPcAuthEndpointService, PcAuthorizeRequest>("api/pc/auth/authorize", (service, request, ctx) => service.AuthorizeAsync(request, ctx))
.WithName("PcAuthorize")
.WithOpenApi("Auth", "PC 授权码登录,生成本地全局 token。", "", typeof(PcAuthorizeRequest), typeof(PcTokenResponse))
.PcOnly();
endpoints.MapPost<IPcAuthEndpointService, PcRefreshRequest>("api/pc/auth/refresh", (service, request, ctx) => service.RefreshAsync(request, ctx))
.WithName("PcRefresh")
.WithOpenApi("Auth", "PC 全局 token 刷新。", "", typeof(PcRefreshRequest), typeof(PcTokenResponse))
.PcOnly();
endpoints.MapPost<IPcAuthEndpointService, PcLogoutRequest>("api/pc/auth/logout", (service, request, ctx) => service.LogoutAsync(request, ctx))
.WithName("PcLogout")
.WithOpenApi("Auth", "PC 退出登录。", "", typeof(PcLogoutRequest))
.PcOnly();
});
}
}
}
@@ -0,0 +1,243 @@
using FileShare_Services.Core;
namespace FileShare_Services.Extensions
{
/// <summary>
/// Desktop (FileShare-PC) 端点适配器。
/// 将统一端点转换为桌面端可用的路由处理器,支持过滤器和鉴权管道。
/// </summary>
public class DesktopEndpointAdapter
{
/// <summary>
/// 统一端点集合。
/// </summary>
private readonly ServiceEndpointCollection _endpoints;
/// <summary>
/// 鉴权服务。
/// </summary>
private readonly IAuthService _authService;
/// <summary>
/// DI 服务提供程序。
/// </summary>
private readonly IServiceProvider _serviceProvider;
/// <summary>
/// 匹配后的路由结果(与原有 RouteDispatchResult 兼容)。
/// </summary>
public class RouteResult
{
/// <summary>
/// 获取是否匹配到路由。
/// </summary>
public bool IsMatched { get; init; }
/// <summary>
/// 获取 HTTP 状态码。
/// </summary>
public int StatusCode { get; init; } = 200;
/// <summary>
/// 获取状态描述文本。
/// </summary>
public string StatusMessage { get; init; } = "";
/// <summary>
/// 获取响应数据。
/// </summary>
public object? Data { get; init; }
/// <summary>
/// 获取响应头字典。
/// </summary>
public Dictionary<string, string> ResponseHeaders { get; init; } = new();
/// <summary>
/// 创建成功响应结果。
/// </summary>
/// <param name="data">响应数据。</param>
/// <param name="ctx">端点上下文。</param>
/// <returns>路由结果。</returns>
public static RouteResult Success(object? data, ServiceEndpointContext ctx)
{
return new RouteResult
{
IsMatched = true,
StatusCode = ctx.StatusCode,
StatusMessage = ctx.StatusMessage,
Data = data,
ResponseHeaders = new Dictionary<string, string>(ctx.ResponseHeaders, StringComparer.OrdinalIgnoreCase),
};
}
/// <summary>
/// 创建 404 未找到响应。
/// </summary>
/// <returns>表示未匹配的路由结果。</returns>
public static RouteResult NotFound() => new()
{
IsMatched = false,
StatusCode = 404,
StatusMessage = "Not Found",
};
}
/// <summary>
/// 初始化桌面端点适配器。
/// </summary>
/// <param name="endpoints">端点集合。</param>
/// <param name="authService">鉴权服务。</param>
/// <param name="serviceProvider">DI 服务提供程序。</param>
public DesktopEndpointAdapter(
ServiceEndpointCollection endpoints,
IAuthService authService,
IServiceProvider serviceProvider)
{
_endpoints = endpoints;
_authService = authService;
_serviceProvider = serviceProvider;
}
/// <summary>
/// 处理来自前端(WebView2 Bridge)的请求。
/// </summary>
/// <param name="path">规范化路径,如 "api/wData"</param>
/// <param name="method">HTTP 方法</param>
/// <param name="body">请求体字符串</param>
/// <param name="headers">请求头字典</param>
/// <param name="query">查询参数字典</param>
public async Task<RouteResult> HandleRequestAsync(
string path,
string method,
string? body,
Dictionary<string, string>? headers = null,
Dictionary<string, string>? query = null)
{
// 查找匹配的端点(忽略大小写 + 方法匹配)
var match = _endpoints.Endpoints
.Where(e =>
e.SupportsHost(EndpointHostTarget.Pc) &&
string.Equals(e.HttpMethod, method, StringComparison.OrdinalIgnoreCase))
.Select(e => new
{
Endpoint = e,
IsMatched = ServiceEndpointPatternMatcher.TryMatch(e.Pattern, path, out var routeValues),
RouteValues = routeValues,
})
.FirstOrDefault(candidate => candidate.IsMatched);
var endpoint = match?.Endpoint;
if (endpoint is null)
{
return RouteResult.NotFound();
}
// 构建上下文
var ctx = new ServiceEndpointContext
{
Path = path,
Method = method,
Body = body,
Headers = headers ?? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase),
Query = query ?? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase),
RouteValues = match!.RouteValues,
Items = { ["ServiceProvider"] = _serviceProvider },
};
try
{
// 1. 鉴权检查
if (endpoint.RequireAuthorization)
{
var user = await _authService.AuthenticateAsync(ctx);
if (user is null)
{
ctx.StatusCode = 401;
ctx.StatusMessage = "Unauthorized";
ctx.ResponseBody = new { success = false, error = "Unauthorized" };
return RouteResult.Success(ctx.ResponseBody, ctx);
}
if (endpoint.Roles.Count > 0)
{
var authorized = await _authService.AuthorizeAsync(user, $"roles:{string.Join(',', endpoint.Roles)}");
if (!authorized)
{
ctx.StatusCode = 403;
ctx.StatusMessage = "Forbidden";
ctx.ResponseBody = new { success = false, error = "Forbidden" };
return RouteResult.Success(ctx.ResponseBody, ctx);
}
}
else if (!string.IsNullOrEmpty(endpoint.Policy))
{
var authorized = await _authService.AuthorizeAsync(user, endpoint.Policy);
if (!authorized)
{
ctx.StatusCode = 403;
ctx.StatusMessage = "Forbidden";
ctx.ResponseBody = new { success = false, error = "Forbidden" };
return RouteResult.Success(ctx.ResponseBody, ctx);
}
}
ctx.Items["User"] = user;
}
// 2. 构建过滤管道:全局过滤器 → 端点过滤器 → 处理器
var pipeline = BuildPipeline(endpoint);
// 3. 执行管道
await pipeline(ctx);
return RouteResult.Success(ctx.ResponseBody, ctx);
}
catch (Exception ex)
{
ctx.StatusCode = 500;
ctx.StatusMessage = "Internal Server Error";
ctx.ResponseBody = new { success = false, error = ex.Message };
return RouteResult.Success(ctx.ResponseBody, ctx);
}
}
/// <summary>
/// 构建过滤管道(全局过滤器 + 端点过滤器 → 端点处理器)。
/// </summary>
private EndpointFilterDelegate BuildPipeline(ServiceEndpoint endpoint)
{
// 最内层:端点处理器
EndpointFilterDelegate handler = async (ctx) =>
{
ctx.ResponseBody = await endpoint.Handler(ctx);
};
// 先包裹端点专属过滤器(后注册的先执行)
var filters = new List<IEndpointFilter>();
filters.AddRange(_endpoints.GlobalFilters);
filters.AddRange(endpoint.Filters);
for (int i = filters.Count - 1; i >= 0; i--)
{
var filter = filters[i];
var next = handler;
handler = (ctx) => filter.InvokeAsync(ctx, next);
}
return handler;
}
}
/// <summary>
/// Desktop 端的辅助扩展。不依赖 IServiceCollection(由宿主项目自行完成 DI 注册)。
/// </summary>
public static class DesktopServiceExtensions
{
/// <summary>
/// 快速构建 DesktopEndpointAdapter(用于非 DI 场景如 MainWindow)。
/// </summary>
public static DesktopEndpointAdapter CreateAdapter(
this ServiceEndpointCollection endpoints,
IServiceProvider serviceProvider)
{
var auth = (serviceProvider.GetService(typeof(IAuthService)) as IAuthService) ?? new AnonymousAuthService();
return new DesktopEndpointAdapter(endpoints, auth, serviceProvider);
}
}
}
@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<RootNamespace>FileShare_Services</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.7" />
<PackageReference Include="QRCoder" Version="1.8.0" />
<PackageReference Include="Serilog" Version="4.2.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
<PackageReference Include="Serilog.Enrichers.Environment" Version="3.0.1" />
<PackageReference Include="Serilog.Enrichers.Thread" Version="4.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\FileShare-Common\FileShare-Common.csproj" />
<ProjectReference Include="..\FileShare-EFCore\FileShare-EFCore.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,98 @@
namespace FileShare_Services.Services.AuthService
{
/// <summary>
/// API 登录请求。
/// </summary>
/// <param name="Account">账号(邮箱或用户名)。</param>
/// <param name="Password">密码。</param>
/// <param name="Roles">请求的角色列表。</param>
public sealed record ApiLoginRequest(string? Account, string? Password, string[]? Roles = null);
/// <summary>
/// API Refresh Token 请求。
/// </summary>
/// <param name="RefreshToken">刷新令牌。</param>
public sealed record ApiRefreshTokenRequest(string? RefreshToken);
/// <summary>
/// API 登出请求。
/// </summary>
/// <param name="RefreshToken">要撤销的刷新令牌。</param>
public sealed record ApiLogoutRequest(string? RefreshToken);
/// <summary>
/// 认证 Token 响应,包含 Access Token 和 Refresh Token 及其过期时间。
/// </summary>
/// <param name="AccessToken">访问令牌。</param>
/// <param name="RefreshToken">刷新令牌。</param>
/// <param name="AccessTokenExpiresAt">访问令牌过期时间。</param>
/// <param name="RefreshTokenExpiresAt">刷新令牌过期时间。</param>
/// <param name="Roles">用户角色列表。</param>
public sealed record AuthTokenResponse(
string AccessToken,
string RefreshToken,
DateTime AccessTokenExpiresAt,
DateTime RefreshTokenExpiresAt,
string[] Roles);
/// <summary>
/// PC 端授权码登录请求。
/// </summary>
/// <param name="AuthorizationCode">第三方授权码。</param>
public sealed record PcAuthorizeRequest(string? AuthorizationCode);
/// <summary>
/// PC 端 Token 刷新请求。
/// </summary>
/// <param name="Token">当前 Token。</param>
public sealed record PcRefreshRequest(string? Token);
/// <summary>
/// PC 端登出请求。
/// </summary>
/// <param name="Token">要清除的 Token。</param>
public sealed record PcLogoutRequest(string? Token);
/// <summary>
/// PC 端 Token 响应。
/// </summary>
/// <param name="Token">访问令牌。</param>
/// <param name="ExpiresAt">过期时间。</param>
/// <param name="Roles">用户角色列表。</param>
public sealed record PcTokenResponse(string Token, DateTime ExpiresAt, string[] Roles);
/// <summary>
/// 第三方授权检查结果。
/// </summary>
public enum ThirdPartyAuthCheckResult
{
/// <summary>授权有效。</summary>
Valid,
/// <summary>授权已丢失。</summary>
AuthorizationLost,
/// <summary>暂时性失败。</summary>
TemporaryFailure,
}
/// <summary>
/// 第三方授权客户端接口,用于验证和刷新第三方授权。
/// </summary>
public interface IPcThirdPartyAuthorizationClient
{
/// <summary>
/// 验证第三方授权码是否有效。
/// </summary>
/// <param name="authorizationCode">第三方授权码。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>授权检查结果。</returns>
Task<ThirdPartyAuthCheckResult> ValidateAuthorizationCodeAsync(string authorizationCode, CancellationToken cancellationToken = default);
/// <summary>
/// 刷新第三方授权。
/// </summary>
/// <param name="authorizationReference">授权引用标识。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>授权检查结果。</returns>
Task<ThirdPartyAuthCheckResult> RefreshAuthorizationAsync(string authorizationReference, CancellationToken cancellationToken = default);
}
}
@@ -0,0 +1,60 @@
using FileShare_Common.Core;
using FileShare_Services.Core;
using System.Threading.Tasks;
namespace FileShare_Services.Services.AuthService
{
/// <summary>
/// API 鉴权端点服务接口,定义登录、刷新 Token 和登出操作。
/// </summary>
public interface IApiAuthEndpointService
{
/// <summary>
/// 处理用户登录请求。
/// </summary>
/// <param name="ctx">服务端点上下文。</param>
/// <returns>包含 Token 的认证响应。</returns>
Task<IApiResponse> LoginAsync(ApiLoginRequest request, ServiceEndpointContext ctx);
/// <summary>
/// 使用 Refresh Token 刷新 Access Token。
/// </summary>
/// <param name="ctx">服务端点上下文。</param>
/// <returns>新的 Token 对。</returns>
Task<IApiResponse> RefreshAsync(ApiRefreshTokenRequest request, ServiceEndpointContext ctx);
/// <summary>
/// 处理用户登出请求。
/// </summary>
/// <param name="ctx">服务端点上下文。</param>
/// <returns>登出结果。</returns>
Task<IApiResponse> LogoutAsync(ApiLogoutRequest request, ServiceEndpointContext ctx);
}
/// <summary>
/// PC 端鉴权端点服务接口,定义授权码登录、Token 刷新和登出操作。
/// </summary>
public interface IPcAuthEndpointService
{
/// <summary>
/// 使用授权码进行登录授权。
/// </summary>
/// <param name="ctx">服务端点上下文。</param>
/// <returns>包含 Token 的认证响应。</returns>
Task<IApiResponse> AuthorizeAsync(PcAuthorizeRequest request, ServiceEndpointContext ctx);
/// <summary>
/// 刷新当前 Token。
/// </summary>
/// <param name="ctx">服务端点上下文。</param>
/// <returns>新的 Token 响应。</returns>
Task<IApiResponse> RefreshAsync(PcRefreshRequest request, ServiceEndpointContext ctx);
/// <summary>
/// 处理用户登出请求。
/// </summary>
/// <param name="ctx">服务端点上下文。</param>
/// <returns>登出结果。</returns>
Task<IApiResponse> LogoutAsync(PcLogoutRequest request, ServiceEndpointContext ctx);
}
}
@@ -0,0 +1,86 @@
using System.Text.Json.Serialization;
namespace FileShare_Services.Services.FileLibrary
{
public sealed record AddLibraryRootRequest(
[property: JsonPropertyName("path")] string? Path,
[property: JsonPropertyName("displayName")] string? DisplayName = null,
[property: JsonPropertyName("scanIntervalMinutes")] int? ScanIntervalMinutes = null);
public sealed record UpdateLibraryRootRequest(
[property: JsonPropertyName("id")] int Id,
[property: JsonPropertyName("isEnabled")] bool IsEnabled);
public sealed record ScanLibraryRootRequest(
[property: JsonPropertyName("id")] int Id);
public sealed record DeleteLibraryRootRequest(
[property: JsonPropertyName("id")] int Id);
public sealed record DirectoryQueryRequest(
[property: JsonPropertyName("path")] string? Path);
public sealed record FileQueryRequest(
[property: JsonPropertyName("id")] int Id);
public sealed record SearchFilesRequest(
[property: JsonPropertyName("page")] int Page = 1,
[property: JsonPropertyName("pageSize")] int PageSize = 24,
[property: JsonPropertyName("mediaType")] string? MediaType = null,
[property: JsonPropertyName("keyword")] string? Keyword = null,
[property: JsonPropertyName("rootId")] int RootId = 0);
public sealed record DriveDto(
string Name,
string DisplayName,
string RootDirectory,
string DriveType,
long? TotalSize,
long? AvailableFreeSpace,
bool IsReady);
public sealed record DirectoryDto(
string Name,
string FullPath);
public sealed record LibraryRootDto(
int Id,
string Path,
string DisplayName,
bool IsEnabled,
bool IsAvailable,
int ScanIntervalMinutes,
DateTime? LastScanStartedAt,
DateTime? LastScanCompletedAt,
string? LastScanError,
int FileCount);
public sealed record FileRecordDto(
int Id,
int LibraryRootId,
string FileName,
string RelativePath,
string Extension,
long SizeBytes,
DateTime LastWriteTimeUtc,
string MediaType,
string ContentType,
string StreamUrl,
string? TextUrl,
bool BrowserPlayable);
public sealed record BrowseDirectoryRequest(
[property: JsonPropertyName("rootId")] int RootId = 0,
[property: JsonPropertyName("path")] string? Path = null);
public sealed record BrowseDirectoryResponse(
string CurrentPath,
List<string> Subdirectories,
List<FileRecordDto> Files);
public sealed record TextPreviewDto(
int Id,
string FileName,
string Content,
bool Truncated);
}
@@ -0,0 +1,86 @@
using FileShare_Common.Core;
using FileShare_Services.Core;
namespace FileShare_Services.Services.FileLibrary
{
public sealed class FileLibraryEndpointService(IFileLibraryService fileLibrary) : IFileLibraryEndpointService
{
public async Task<IApiResponse> GetDrivesAsync(ServiceEndpointContext ctx)
{
return ResponseHelper.Ok(await fileLibrary.GetDrivesAsync());
}
public async Task<IApiResponse> GetDirectoriesAsync(DirectoryQueryRequest request)
{
return ResponseHelper.Ok(await fileLibrary.GetDirectoriesAsync(request.Path));
}
public async Task<IApiResponse> GetRootsAsync(ServiceEndpointContext ctx)
{
return ResponseHelper.Ok(await fileLibrary.GetRootsAsync());
}
public async Task<IApiResponse> AddRootAsync(AddLibraryRootRequest request)
{
return ResponseHelper.Ok(await fileLibrary.AddRootAsync(request), "文件库目录已添加并完成扫描。");
}
public async Task<IApiResponse> SetRootEnabledAsync(UpdateLibraryRootRequest request)
{
return ResponseHelper.Ok(await fileLibrary.SetRootEnabledAsync(request), "文件库目录状态已更新。");
}
public async Task<IApiResponse> DeleteRootAsync(DeleteLibraryRootRequest request)
{
await fileLibrary.DeleteRootAsync(request);
return ResponseHelper.Succeed("文件库目录已删除。");
}
public async Task<IApiResponse> ScanRootAsync(ScanLibraryRootRequest request)
{
return ResponseHelper.Ok(await fileLibrary.ScanRootAsync(request.Id), "文件库目录扫描完成。");
}
public async Task<IApiResponse> SearchFilesAsync(SearchFilesRequest request)
{
return await fileLibrary.SearchFilesAsync(request);
}
public async Task<IApiResponse> GetFileAsync(FileQueryRequest request)
{
ValidateFileId(request.Id);
var file = await fileLibrary.GetFileAsync(request.Id);
return file is null
? ResponseHelper.Failure(404, "文件不存在或尚未扫描入库。")
: ResponseHelper.Ok(file);
}
public async Task<IApiResponse> GetTextPreviewAsync(FileQueryRequest request)
{
ValidateFileId(request.Id);
var preview = await fileLibrary.GetTextPreviewAsync(request.Id);
return preview is null
? ResponseHelper.Failure(404, "文本文件不存在或无法预览。")
: ResponseHelper.Ok(preview);
}
public async Task<IApiResponse> BrowseDirectoryAsync(BrowseDirectoryRequest request)
{
if (request.RootId <= 0)
return ResponseHelper.Failure(400, "rootId 参数无效。");
var result = await fileLibrary.BrowseDirectoryAsync(request);
return ResponseHelper.Ok(result);
}
private static void ValidateFileId(int id)
{
if (id > 0)
{
return;
}
throw new ArgumentException("id 参数无效。");
}
}
}
@@ -0,0 +1,451 @@
using FileShare_Common.Core;
using FileShare_EFCore.Database;
using FileShare_EFCore.Models;
using FileShare_Services.Core;
using Microsoft.EntityFrameworkCore;
using System.Text;
namespace FileShare_Services.Services.FileLibrary
{
public sealed class FileLibraryService(AppDataContext db) : IFileLibraryService
{
private const int DefaultScanIntervalMinutes = 5;
private const int MaxTextPreviewBytes = 1024 * 1024;
public Task<List<DriveDto>> GetDrivesAsync(CancellationToken cancellationToken = default)
{
var drives = DriveInfo.GetDrives()
.Select(drive => new DriveDto(
drive.Name,
drive.IsReady ? $"{drive.Name} ({drive.VolumeLabel})" : drive.Name,
drive.RootDirectory.FullName,
drive.DriveType.ToString(),
SafeDriveValue(drive, d => d.TotalSize),
SafeDriveValue(drive, d => d.AvailableFreeSpace),
drive.IsReady))
.OrderBy(drive => drive.Name)
.ToList();
return Task.FromResult(drives);
}
public Task<List<DirectoryDto>> GetDirectoriesAsync(string? path, CancellationToken cancellationToken = default)
{
var normalized = NormalizeExistingDirectory(path);
var directories = Directory.EnumerateDirectories(normalized)
.Select(directory => new DirectoryInfo(directory))
.OrderBy(directory => directory.Name)
.Select(directory => new DirectoryDto(directory.Name, directory.FullName))
.ToList();
return Task.FromResult(directories);
}
public async Task<List<LibraryRootDto>> GetRootsAsync(CancellationToken cancellationToken = default)
{
var counts = await db.ManagedFileRecords
.Where(file => file.Exists)
.GroupBy(file => file.LibraryRootId)
.Select(group => new { RootId = group.Key, Count = group.Count() })
.ToDictionaryAsync(item => item.RootId, item => item.Count, cancellationToken);
var roots = await db.ManagedLibraryRoots
.OrderBy(root => root.Path)
.ToListAsync(cancellationToken);
return roots.Select(root => ToRootDto(root, counts.GetValueOrDefault(root.Id))).ToList();
}
public async Task<LibraryRootDto> AddRootAsync(AddLibraryRootRequest request, CancellationToken cancellationToken = default)
{
var normalized = NormalizeExistingDirectory(request.Path);
var existing = await db.ManagedLibraryRoots.FirstOrDefaultAsync(root => root.Path == normalized, cancellationToken);
if (existing is not null)
{
existing.IsEnabled = true;
existing.IsAvailable = true;
existing.DisplayName = ResolveDisplayName(normalized, request.DisplayName);
existing.ScanIntervalMinutes = NormalizeInterval(request.ScanIntervalMinutes);
await db.SaveChangesAsync(cancellationToken);
return await ScanRootAsync(existing.Id, cancellationToken);
}
var root = new ManagedLibraryRoot
{
Path = normalized,
DisplayName = ResolveDisplayName(normalized, request.DisplayName),
ScanIntervalMinutes = NormalizeInterval(request.ScanIntervalMinutes),
IsEnabled = true,
IsAvailable = true,
};
db.ManagedLibraryRoots.Add(root);
await db.SaveChangesAsync(cancellationToken);
return await ScanRootAsync(root.Id, cancellationToken);
}
public async Task<LibraryRootDto> SetRootEnabledAsync(UpdateLibraryRootRequest request, CancellationToken cancellationToken = default)
{
var root = await db.ManagedLibraryRoots.FirstOrDefaultAsync(item => item.Id == request.Id, cancellationToken)
?? throw new InvalidOperationException("文件库目录不存在。");
root.IsEnabled = request.IsEnabled;
await db.SaveChangesAsync(cancellationToken);
var count = await db.ManagedFileRecords.CountAsync(file => file.LibraryRootId == root.Id && file.Exists, cancellationToken);
return ToRootDto(root, count);
}
public async Task DeleteRootAsync(DeleteLibraryRootRequest request, CancellationToken cancellationToken = default)
{
var root = await db.ManagedLibraryRoots.FirstOrDefaultAsync(item => item.Id == request.Id, cancellationToken)
?? throw new InvalidOperationException("文件库目录不存在。");
db.ManagedLibraryRoots.Remove(root);
await db.SaveChangesAsync(cancellationToken);
}
public async Task<LibraryRootDto> ScanRootAsync(int rootId, CancellationToken cancellationToken = default)
{
var root = await db.ManagedLibraryRoots.FirstOrDefaultAsync(item => item.Id == rootId, cancellationToken)
?? throw new InvalidOperationException("文件库目录不存在。");
root.LastScanStartedAt = DateTime.UtcNow;
root.LastScanError = null;
await db.SaveChangesAsync(cancellationToken);
try
{
if (!Directory.Exists(root.Path))
{
throw new DirectoryNotFoundException($"目录不存在:{root.Path}");
}
root.IsAvailable = true;
root.IsEnabled = true;
var existing = await db.ManagedFileRecords
.Where(file => file.LibraryRootId == root.Id)
.ToDictionaryAsync(file => file.AbsolutePath, StringComparer.OrdinalIgnoreCase, cancellationToken);
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var path in EnumerateSupportedFiles(root.Path))
{
cancellationToken.ThrowIfCancellationRequested();
var info = new FileInfo(path);
if (!info.Exists || !MediaFileTypes.TryGet(info.Extension.ToLowerInvariant(), out var mediaType, out var contentType, out _))
{
continue;
}
var absolutePath = info.FullName;
seen.Add(absolutePath);
if (!existing.TryGetValue(absolutePath, out var record))
{
record = new ManagedFileRecord
{
LibraryRootId = root.Id,
AbsolutePath = absolutePath,
};
db.ManagedFileRecords.Add(record);
}
record.FileName = info.Name;
record.RelativePath = Path.GetRelativePath(root.Path, absolutePath);
record.Extension = info.Extension.ToLowerInvariant();
record.SizeBytes = info.Length;
record.LastWriteTimeUtc = info.LastWriteTimeUtc;
record.MediaType = mediaType;
record.ContentType = contentType;
record.Exists = true;
record.LastSeenAt = DateTime.UtcNow;
}
foreach (var stale in existing.Values.Where(file => !seen.Contains(file.AbsolutePath)))
{
stale.Exists = false;
}
root.LastScanCompletedAt = DateTime.UtcNow;
await db.SaveChangesAsync(cancellationToken);
}
catch (Exception ex)
{
root.IsAvailable = false;
root.IsEnabled = false;
root.LastScanError = ex.Message;
root.LastScanCompletedAt = DateTime.UtcNow;
await db.SaveChangesAsync(CancellationToken.None);
throw;
}
var count = await db.ManagedFileRecords.CountAsync(file => file.LibraryRootId == root.Id && file.Exists, cancellationToken);
return ToRootDto(root, count);
}
public async Task ScanDueRootsAsync(CancellationToken cancellationToken = default)
{
var now = DateTime.UtcNow;
var roots = await db.ManagedLibraryRoots
.Where(root => root.IsEnabled && root.IsAvailable)
.ToListAsync(cancellationToken);
foreach (var root in roots)
{
var interval = Math.Max(1, root.ScanIntervalMinutes);
var isDue = root.LastScanCompletedAt is null || root.LastScanCompletedAt.Value.AddMinutes(interval) <= now;
if (!isDue)
{
continue;
}
try
{
await ScanRootAsync(root.Id, cancellationToken);
}
catch
{
// ScanRootAsync records the error on the root. Continue scanning other roots.
}
}
}
public async Task<PagedResponse<FileRecordDto>> SearchFilesAsync(SearchFilesRequest request, CancellationToken cancellationToken = default)
{
var page = Math.Clamp(request.Page, 1, 100000);
var pageSize = Math.Clamp(request.PageSize, 1, 100);
var mediaType = request.MediaType?.Trim();
var keyword = request.Keyword?.Trim();
var rootId = Math.Clamp(request.RootId, 0, int.MaxValue);
var query = db.ManagedFileRecords
.AsNoTracking()
.Where(file => file.Exists && file.LibraryRoot != null && file.LibraryRoot.IsAvailable);
if (!string.IsNullOrWhiteSpace(mediaType) && !mediaType.Equals("all", StringComparison.OrdinalIgnoreCase))
{
query = query.Where(file => file.MediaType == mediaType);
}
if (rootId > 0)
{
query = query.Where(file => file.LibraryRootId == rootId);
}
if (!string.IsNullOrWhiteSpace(keyword))
{
query = query.Where(file => file.FileName.Contains(keyword) || file.RelativePath.Contains(keyword));
}
var total = await query.CountAsync(cancellationToken);
var items = await query
.OrderBy(file => file.MediaType)
.ThenBy(file => file.RelativePath)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(file => ToFileDto(file))
.ToListAsync(cancellationToken);
return PagedResponse<FileRecordDto>.From(items, total, page, pageSize);
}
public async Task<FileRecordDto?> GetFileAsync(int id, CancellationToken cancellationToken = default)
{
return await db.ManagedFileRecords
.AsNoTracking()
.Where(file => file.Id == id && file.Exists && file.LibraryRoot != null && file.LibraryRoot.IsAvailable)
.Select(file => ToFileDto(file))
.FirstOrDefaultAsync(cancellationToken);
}
public async Task<BrowseDirectoryResponse> BrowseDirectoryAsync(BrowseDirectoryRequest request, CancellationToken cancellationToken = default)
{
var rootId = request.RootId;
// URL 友好的正斜杠,用于响应和内存处理
var prefix = (request.Path ?? "").Trim().Replace('\\', '/').Trim('/');
// Windows 反斜杠,用于数据库查询
var dbPrefix = prefix.Replace('/', '\\');
var query = db.ManagedFileRecords
.AsNoTracking()
.Where(f => f.LibraryRootId == rootId && f.Exists
&& f.LibraryRoot != null && f.LibraryRoot.IsAvailable);
if (!string.IsNullOrEmpty(dbPrefix))
{
var dbPrefixWithSlash = dbPrefix + "\\";
query = query.Where(f => f.RelativePath.StartsWith(dbPrefixWithSlash));
}
var allFiles = await query.ToListAsync(cancellationToken);
var subdirs = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var currentFiles = new List<FileRecordDto>();
foreach (var file in allFiles)
{
var relativePath = file.RelativePath.Replace('\\', '/');
var remaining = string.IsNullOrEmpty(prefix)
? relativePath
: relativePath[(prefix.Length + 1)..];
var slashIndex = remaining.IndexOf('/');
if (slashIndex < 0)
{
currentFiles.Add(ToFileDto(file));
}
else
{
subdirs.Add(remaining[..slashIndex]);
}
}
return new BrowseDirectoryResponse(
prefix,
subdirs.OrderBy(d => d, StringComparer.OrdinalIgnoreCase).ToList(),
currentFiles);
}
public async Task<TextPreviewDto?> GetTextPreviewAsync(int id, CancellationToken cancellationToken = default)
{
var file = await db.ManagedFileRecords
.AsNoTracking()
.Include(item => item.LibraryRoot)
.FirstOrDefaultAsync(item =>
item.Id == id
&& item.Exists
&& item.MediaType == "text"
&& item.LibraryRoot != null
&& item.LibraryRoot.IsAvailable,
cancellationToken);
if (file is null || !File.Exists(file.AbsolutePath))
{
return null;
}
await using var stream = File.OpenRead(file.AbsolutePath);
var limit = (int)Math.Min(stream.Length, MaxTextPreviewBytes);
var buffer = new byte[limit];
var read = await stream.ReadAsync(buffer.AsMemory(0, limit), cancellationToken);
var content = Encoding.UTF8.GetString(buffer, 0, read);
return new TextPreviewDto(file.Id, file.FileName, content, stream.Length > MaxTextPreviewBytes);
}
private static IEnumerable<string> EnumerateSupportedFiles(string rootPath)
{
var pending = new Stack<string>();
pending.Push(rootPath);
while (pending.Count > 0)
{
var current = pending.Pop();
IEnumerable<string> directories;
IEnumerable<string> files;
try
{
directories = Directory.EnumerateDirectories(current);
files = Directory.EnumerateFiles(current);
}
catch
{
continue;
}
foreach (var directory in directories)
{
pending.Push(directory);
}
foreach (var file in files)
{
if (MediaFileTypes.TryGet(Path.GetExtension(file), out _, out _, out _))
{
yield return file;
}
}
}
}
private static string NormalizeExistingDirectory(string? path)
{
if (string.IsNullOrWhiteSpace(path))
{
throw new InvalidOperationException("目录路径不能为空。");
}
var fullPath = Path.GetFullPath(path.Trim());
if (!Directory.Exists(fullPath))
{
throw new DirectoryNotFoundException($"目录不存在:{fullPath}");
}
return new DirectoryInfo(fullPath).FullName;
}
private static string ResolveDisplayName(string path, string? displayName)
{
if (!string.IsNullOrWhiteSpace(displayName))
{
return displayName.Trim();
}
var directory = new DirectoryInfo(path);
return string.IsNullOrWhiteSpace(directory.Name) ? directory.FullName : directory.Name;
}
private static int NormalizeInterval(int? interval)
{
return Math.Clamp(interval ?? DefaultScanIntervalMinutes, 1, 1440);
}
private static long? SafeDriveValue(DriveInfo drive, Func<DriveInfo, long> selector)
{
try
{
return drive.IsReady ? selector(drive) : null;
}
catch
{
return null;
}
}
private static LibraryRootDto ToRootDto(ManagedLibraryRoot root, int fileCount)
{
return new LibraryRootDto(
root.Id,
root.Path,
root.DisplayName,
root.IsEnabled,
root.IsAvailable,
root.ScanIntervalMinutes,
root.LastScanStartedAt,
root.LastScanCompletedAt,
root.LastScanError,
fileCount);
}
private static FileRecordDto ToFileDto(ManagedFileRecord file)
{
return new FileRecordDto(
file.Id,
file.LibraryRootId,
file.FileName,
file.RelativePath,
file.Extension,
file.SizeBytes,
file.LastWriteTimeUtc,
file.MediaType,
file.ContentType,
$"/api/files/{file.Id}/stream",
file.MediaType == "text" ? $"/api/files/text?id={file.Id}" : null,
MediaFileTypes.IsBrowserPlayable(file.Extension));
}
}
}
@@ -0,0 +1,37 @@
using FileShare_EFCore.Database;
using FileShare_EFCore.Models;
using FileShare_Services.Core;
using Microsoft.EntityFrameworkCore;
namespace FileShare_Services.Services.FileLibrary
{
public interface IFileStreamService
{
Task<FileStreamResponse?> GetFileStreamAsync(int id, CancellationToken cancellationToken = default);
}
public sealed class FileStreamService(AppDataContext db) : IFileStreamService
{
public async Task<FileStreamResponse?> GetFileStreamAsync(int id, CancellationToken cancellationToken = default)
{
var file = await db.ManagedFileRecords
.AsNoTracking()
.Include(item => item.LibraryRoot)
.FirstOrDefaultAsync(item =>
item.Id == id
&& item.Exists
&& item.LibraryRoot != null
&& item.LibraryRoot.IsAvailable,
cancellationToken);
if (file is null || !System.IO.File.Exists(file.AbsolutePath))
return null;
return new FileStreamResponse(
file.AbsolutePath,
file.FileName,
file.ContentType,
file.LastWriteTimeUtc);
}
}
}
@@ -0,0 +1,30 @@
using FileShare_Common.Core;
using FileShare_Services.Core;
namespace FileShare_Services.Services.FileLibrary
{
public interface IFileLibraryEndpointService
{
Task<IApiResponse> GetDrivesAsync(ServiceEndpointContext ctx);
Task<IApiResponse> GetDirectoriesAsync(DirectoryQueryRequest request);
Task<IApiResponse> GetRootsAsync(ServiceEndpointContext ctx);
Task<IApiResponse> AddRootAsync(AddLibraryRootRequest request);
Task<IApiResponse> SetRootEnabledAsync(UpdateLibraryRootRequest request);
Task<IApiResponse> DeleteRootAsync(DeleteLibraryRootRequest request);
Task<IApiResponse> ScanRootAsync(ScanLibraryRootRequest request);
Task<IApiResponse> SearchFilesAsync(SearchFilesRequest request);
Task<IApiResponse> GetFileAsync(FileQueryRequest request);
Task<IApiResponse> GetTextPreviewAsync(FileQueryRequest request);
Task<IApiResponse> BrowseDirectoryAsync(BrowseDirectoryRequest request);
}
}
@@ -0,0 +1,31 @@
using FileShare_Common.Core;
namespace FileShare_Services.Services.FileLibrary
{
public interface IFileLibraryService
{
Task<List<DriveDto>> GetDrivesAsync(CancellationToken cancellationToken = default);
Task<List<DirectoryDto>> GetDirectoriesAsync(string? path, CancellationToken cancellationToken = default);
Task<List<LibraryRootDto>> GetRootsAsync(CancellationToken cancellationToken = default);
Task<LibraryRootDto> AddRootAsync(AddLibraryRootRequest request, CancellationToken cancellationToken = default);
Task<LibraryRootDto> SetRootEnabledAsync(UpdateLibraryRootRequest request, CancellationToken cancellationToken = default);
Task DeleteRootAsync(DeleteLibraryRootRequest request, CancellationToken cancellationToken = default);
Task<LibraryRootDto> ScanRootAsync(int rootId, CancellationToken cancellationToken = default);
Task ScanDueRootsAsync(CancellationToken cancellationToken = default);
Task<PagedResponse<FileRecordDto>> SearchFilesAsync(SearchFilesRequest request, CancellationToken cancellationToken = default);
Task<FileRecordDto?> GetFileAsync(int id, CancellationToken cancellationToken = default);
Task<TextPreviewDto?> GetTextPreviewAsync(int id, CancellationToken cancellationToken = default);
Task<BrowseDirectoryResponse> BrowseDirectoryAsync(BrowseDirectoryRequest request, CancellationToken cancellationToken = default);
}
}
@@ -0,0 +1,51 @@
namespace FileShare_Services.Services.FileLibrary
{
public static class MediaFileTypes
{
private static readonly Dictionary<string, (string MediaType, string ContentType, bool BrowserPlayable)> Types =
new(StringComparer.OrdinalIgnoreCase)
{
[".txt"] = ("text", "text/plain; charset=utf-8", true),
[".log"] = ("text", "text/plain; charset=utf-8", true),
[".json"] = ("text", "application/json; charset=utf-8", true),
[".xml"] = ("text", "application/xml; charset=utf-8", true),
[".csv"] = ("text", "text/csv; charset=utf-8", true),
[".md"] = ("text", "text/markdown; charset=utf-8", true),
[".ini"] = ("text", "text/plain; charset=utf-8", true),
[".yml"] = ("text", "text/yaml; charset=utf-8", true),
[".yaml"] = ("text", "text/yaml; charset=utf-8", true),
[".mp4"] = ("video", "video/mp4", true),
[".webm"] = ("video", "video/webm", true),
[".ogg"] = ("video", "video/ogg", true),
[".ogv"] = ("video", "video/ogg", true),
[".mov"] = ("video", "video/quicktime", false),
[".mkv"] = ("video", "video/x-matroska", false),
[".mp3"] = ("audio", "audio/mpeg", true),
[".wav"] = ("audio", "audio/wav", true),
[".m4a"] = ("audio", "audio/mp4", true),
[".aac"] = ("audio", "audio/aac", true),
[".oga"] = ("audio", "audio/ogg", true),
};
public static bool TryGet(string extension, out string mediaType, out string contentType, out bool browserPlayable)
{
if (Types.TryGetValue(extension, out var value))
{
mediaType = value.MediaType;
contentType = value.ContentType;
browserPlayable = value.BrowserPlayable;
return true;
}
mediaType = string.Empty;
contentType = "application/octet-stream";
browserPlayable = false;
return false;
}
public static bool IsBrowserPlayable(string extension)
{
return Types.TryGetValue(extension, out var value) && value.BrowserPlayable;
}
}
}
@@ -0,0 +1,9 @@
using FileShare_Services.Core;
namespace FileShare_Services.Services.QrCode
{
public interface IQrCodeService
{
Task<object?> GenerateQrCodeAsync(ServiceEndpointContext ctx);
}
}
@@ -0,0 +1,4 @@
namespace FileShare_Services.Services.QrCode
{
public sealed record QrCodeResponse(string Url, string QrCodeBase64);
}
@@ -0,0 +1,46 @@
using FileShare_Common.Core;
using FileShare_Services.Core;
using QRCoder;
using System.Net;
using System.Net.NetworkInformation;
using System.Net.Sockets;
namespace FileShare_Services.Services.QrCode
{
public sealed class QrCodeService : IQrCodeService
{
public Task<object?> GenerateQrCodeAsync(ServiceEndpointContext ctx)
{
var ip = GetLanIpAddress();
if (ip is null)
throw new InvalidOperationException("无法获取局域网IP地址");
var url = $"http://{ip}:5206";
var base64 = GeneratePngBase64(url);
return Task.FromResult<object?>(ResponseHelper.Ok(new QrCodeResponse(url, base64)));
}
private static string GeneratePngBase64(string content)
{
using var generator = new QRCodeGenerator();
using var data = generator.CreateQrCode(content, QRCodeGenerator.ECCLevel.Q);
using var png = new PngByteQRCode(data);
var bytes = png.GetGraphic(20);
return $"data:image/png;base64,{Convert.ToBase64String(bytes)}";
}
private static string? GetLanIpAddress()
{
return NetworkInterface.GetAllNetworkInterfaces()
.Where(ni => ni.OperationalStatus == OperationalStatus.Up
&& ni.NetworkInterfaceType != NetworkInterfaceType.Loopback)
.SelectMany(ni => ni.GetIPProperties().UnicastAddresses)
.Select(ua => ua.Address)
.FirstOrDefault(ip =>
ip.AddressFamily == AddressFamily.InterNetwork
&& !IPAddress.IsLoopback(ip)
&& !ip.ToString().StartsWith("169.254"))
?.ToString();
}
}
}
@@ -0,0 +1,30 @@
using FileShare_EFCore.Models;
namespace FileShare_Services.Services
{
/// <summary>
/// 天气预报服务,随机生成未来 5 天的天气预报数据。
/// </summary>
public class WeatherForecastService
{
private static readonly string[] Summaries =
[
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
];
/// <summary>
/// 生成未来 5 天的随机天气预报数据。
/// </summary>
/// <returns>天气预报数据集合。</returns>
public IEnumerable<WeatherForecast> GetWeatherForecasts()
{
return Enumerable.Range(1, 5).Select(index => new WeatherForecast
{
Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
TemperatureC = Random.Shared.Next(-20, 55),
Summary = Summaries[Random.Shared.Next(Summaries.Length)]
})
.ToArray();
}
}
}