This commit is contained in:
luoqian 2026-05-21 15:52:36 +08:00
commit e3fe965f10
138 changed files with 15087 additions and 0 deletions

13
.config/dotnet-tools.json Normal file
View File

@ -0,0 +1,13 @@
{
"version": 1,
"isRoot": true,
"tools": {
"dotnet-ef": {
"version": "10.0.7",
"commands": [
"dotnet-ef"
],
"rollForward": false
}
}
}

30
.gitignore vendored Normal file
View File

@ -0,0 +1,30 @@
################################################################################
# 此 .gitignore 文件已由 Microsoft(R) Visual Studio 自动创建。
################################################################################
/Avalonia-PC/bin
/Avalonia-PC/.vs
/Avalonia-PC/obj
/Avalonia-API/bin
/Avalonia-API/obj
/Avalonia-Services/bin
/Avalonia-Services/obj
/Avalonia-Web-VUE/.vscode
/Avalonia-Web-VUE/obj
/Avalonia-Web-VUE/node_modules
/Avalonia-Web-VUE/dist
/Avalonia-Web-VUE/.vscode
/avalonia-web-react/obj
/avalonia-web-react/obj
/avalonia-web-react/node_modules
/avalonia-web-react/dist
/Avalonia-EFCore/bin
/Avalonia-EFCore/obj
/Avalonia-Common/bin
/Avalonia-Common/obj
/Avalonia-API/logs
/Avalonia-API/avalonia-api.db
/Avalonia-API/avalonia-api.db-shm
/Avalonia-API/avalonia-api.db-wal
/package-output
/package-scripts/tools

7
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,7 @@
{
"chat.tools.terminal.autoApprove": {
"ForEach-Object": true,
"dotnet list": true,
"dotnet build": true
}
}

View File

@ -0,0 +1,160 @@
using Avalonia_Common.Core;
using Avalonia_EFCore.Database;
using Avalonia_EFCore.Models;
using Avalonia_Services.Core;
using Avalonia_Services.Services.AuthService;
using Microsoft.EntityFrameworkCore;
using System.Text.Json;
namespace Avalonia_API.Authentication
{
/// <summary>
/// API 鉴权端点服务,实现 <see cref="IApiAuthEndpointService"/>
/// 处理登录、刷新 Token 和登出操作,使用 JWT 与 Refresh Token 机制。
/// </summary>
public sealed class ApiAuthEndpointService(
AppDataContext db,
JwtTokenService jwtTokenService,
RefreshTokenService refreshTokenService) : IApiAuthEndpointService
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
};
/// <summary>
/// 处理用户登录请求。根据账号(邮箱或用户名)查找或创建用户,
/// 生成 JWT Access Token 和 Refresh Token 并返回。
/// </summary>
/// <param name="ctx">服务端点上下文,包含请求体、请求头等信息。</param>
/// <returns>包含 AccessToken、RefreshToken 及过期时间的认证响应。</returns>
public async Task<object?> LoginAsync(ServiceEndpointContext ctx)
{
var request = Deserialize<ApiLoginRequest>(ctx.Body);
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<object?> RefreshAsync(ServiceEndpointContext ctx)
{
var request = Deserialize<ApiRefreshTokenRequest>(ctx.Body);
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<object?> LogoutAsync(ServiceEndpointContext ctx)
{
var request = Deserialize<ApiLogoutRequest>(ctx.Body);
await refreshTokenService.RevokeAsync(request?.RefreshToken);
return ResponseHelper.Succeed("退出成功");
}
/// <summary>
/// 将 JSON 请求体反序列化为指定类型。
/// </summary>
/// <typeparam name="T">目标类型。</typeparam>
/// <param name="body">JSON 请求体字符串,可为空。</param>
/// <returns>反序列化后的对象;若 body 为空则返回默认值。</returns>
private static T? Deserialize<T>(string? body)
{
return string.IsNullOrWhiteSpace(body)
? default
: JsonSerializer.Deserialize<T>(body, JsonOptions);
}
/// <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"];
}
}
}

View File

@ -0,0 +1,33 @@
namespace Avalonia_API.Authentication
{
/// <summary>
/// JWT 鉴权配置选项,从 appsettings.json 的 Jwt 节绑定。
/// </summary>
public sealed class JwtOptions
{
/// <summary>
/// 获取或设置 Token 签发者。
/// </summary>
public string Issuer { get; set; } = "Avalonia-API";
/// <summary>
/// 获取或设置 Token 受众。
/// </summary>
public string Audience { get; set; } = "Avalonia-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;
}
}

View File

@ -0,0 +1,55 @@
using Avalonia_EFCore.Models;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
namespace Avalonia_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);
}
}
}

View File

@ -0,0 +1,124 @@
using Avalonia_EFCore.Database;
using Avalonia_EFCore.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using System.Security.Cryptography;
using System.Text;
namespace Avalonia_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);
}
}
}

View File

@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>Avalonia_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="..\Avalonia-Services\Avalonia-Services.csproj" />
<ProjectReference Include="..\Avalonia-Common\Avalonia-Common.csproj" />
<ProjectReference Include="..\Avalonia-EFCore\Avalonia-EFCore.csproj" />
</ItemGroup>
<ItemGroup>
<Folder Include="Controllers\" />
</ItemGroup>
</Project>

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>

View File

@ -0,0 +1,6 @@
@Avalonia_API_HostAddress = http://localhost:5206
GET {{Avalonia_API_HostAddress}}/weatherforecast/
Accept: application/json
###

View File

@ -0,0 +1,75 @@
using Avalonia_API.Authentication;
using Avalonia_EFCore.Database;
using Avalonia_Services.Core;
using Avalonia_Services.Endpoints;
using Avalonia_Services.Services;
using Avalonia_Services.Services.AuthService;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using System.Text;
namespace Avalonia_API.Configuration
{
/// <summary>
/// API 项目服务配置扩展类,负责注册数据库、鉴权、业务服务和统一端点。
/// </summary>
public static class ServicesConfiguration
{
/// <summary>
/// 注册统一端点及其依赖的服务(含数据库)。
/// 所有业务端点定义在 Avalonia-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>();
// ---- 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;
}
}
}

View File

@ -0,0 +1,225 @@
using Avalonia_Services.Core;
using Microsoft.AspNetCore.Authorization;
using AspNetCoreFilterContext = Microsoft.AspNetCore.Http.EndpointFilterInvocationContext;
using AspNetCoreFilterDelegate = Microsoft.AspNetCore.Http.EndpointFilterDelegate;
// 解决与 ASP.NET Core 同名类型的冲突
using UnifiedFilter = Avalonia_Services.Core.IEndpointFilter;
namespace Avalonia_API.Extensions
{
/// <summary>
/// 将 Avalonia-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)
{
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 = await BuildContextFromHttpContext(httpContext);
ctx.Items["ServiceProvider"] = serviceProvider;
ctx.Items["User"] = httpContext.User;
var result = await unifiedHandler(ctx);
// 同步响应状态
httpContext.Response.StatusCode = ctx.StatusCode;
foreach (var kvp in ctx.ResponseHeaders)
{
httpContext.Response.Headers[kvp.Key] = kvp.Value;
}
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();
}
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;
await unifiedFilter.InvokeAsync(ctx, async (c) =>
{
httpContext.Response.StatusCode = c.StatusCode;
foreach (var kvp in c.ResponseHeaders)
{
httpContext.Response.Headers[kvp.Key] = kvp.Value;
}
await aspNext(aspContext);
});
if (ctx.ResponseBody is not null)
{
return Results.Json(ctx.ResponseBody, statusCode: ctx.StatusCode);
}
return null!;
}
}
}

60
Avalonia-API/Program.cs Normal file
View File

@ -0,0 +1,60 @@
using Avalonia_API.Configuration;
using Avalonia_API.Extensions;
using Avalonia_Common.Infrastructure;
using Avalonia_EFCore.Database;
using Avalonia_Services.Core;
using Serilog;
// 初始化日志系统
Log.Logger = LoggingConfiguration.CreateDefaultLogger(logDir: "logs");
Log.Information("Avalonia-API 正在启动...");
try
{
var builder = WebApplication.CreateBuilder(args);
// 使用 Serilog 作为日志提供程序
builder.Host.UseSerilog();
// Add services to the container.
builder.Services.AddControllers();
builder.Services.AddOpenApi();
// 注册统一端点及业务服务(入口在 Avalonia-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", "Avalonia API v1");
options.RoutePrefix = "swagger";
});
}
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
// 将统一端点映射到 ASP.NET Core 路由
app.MapUnifiedEndpoints(endpoints, app.Services);
app.Run();
}
catch (Exception ex)
{
Log.Fatal(ex, "Avalonia-API 启动失败");
}
finally
{
Log.CloseAndFlush();
}

View File

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

View File

@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@ -0,0 +1,24 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"Jwt": {
"Issuer": "Avalonia-API",
"Audience": "Avalonia-Client",
"SigningKey": "change-this-development-signing-key-at-least-32-bytes",
"AccessTokenMinutes": 60,
"RefreshTokenDays": 30
},
"DatabaseConfiguration": {
"Provider": "MySQL",
"ConnectionString": "Server=127.0.0.1;Port=3306;Database=avalonia-api;Uid=root;Pwd=123456;Max Pool Size=100;Min Pool Size=5;AllowZeroDateTime=True;AllowLoadLocalInfile=true;SslMode=Required",
"AutoMigrate": true,
"RecreateDatabase": false,
"EnableDetailedLog": false,
"Timeout": 30
}
}

View File

@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<RootNamespace>Avalonia_Common</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<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>
</Project>

View File

@ -0,0 +1,205 @@
using System.Text.Json.Serialization;
namespace Avalonia_Common.Core
{
/// <summary>
/// 统一 API 返回格式。
/// 所有接口的返回都包装为此格式,确保前端收到一致的数据结构。
/// </summary>
/// <typeparam name="T">业务数据类型</typeparam>
public class ApiResponse<T>
{
/// <summary>是否成功</summary>
[JsonPropertyName("success")]
public bool Success { get; set; }
/// <summary>HTTP 状态码</summary>
[JsonPropertyName("code")]
public int Code { get; set; }
/// <summary>消息(成功时可为 null失败时包含错误描述</summary>
[JsonPropertyName("message")]
public string? Message { get; set; }
/// <summary>业务数据</summary>
[JsonPropertyName("data")]
public T? Data { get; set; }
/// <summary>时间戳</summary>
[JsonPropertyName("timestamp")]
public DateTime Timestamp { get; set; } = DateTime.Now;
/// <summary>请求追踪 ID用于排查问题</summary>
[JsonPropertyName("traceId")]
public string? TraceId { get; set; }
// ---- 快捷工厂方法 ----
/// <summary>成功返回(有数据)</summary>
public static ApiResponse<T> Ok(T data, string? message = null)
{
return new ApiResponse<T>
{
Success = true,
Code = 200,
Message = message,
Data = data,
};
}
/// <summary>失败返回</summary>
public static ApiResponse<T> Fail(int code, string message, T? data = default)
{
return new ApiResponse<T>
{
Success = false,
Code = code,
Message = message,
Data = data,
};
}
/// <summary>400 参数错误</summary>
public static ApiResponse<T> BadRequest(string message = "参数错误")
=> Fail(400, message);
/// <summary>401 未授权</summary>
public static ApiResponse<T> Unauthorized(string message = "未授权")
=> Fail(401, message);
/// <summary>403 无权限</summary>
public static ApiResponse<T> Forbidden(string message = "无权限")
=> Fail(403, message);
/// <summary>404 未找到</summary>
public static ApiResponse<T> NotFound(string message = "资源不存在")
=> Fail(404, message);
/// <summary>500 服务器内部错误</summary>
public static ApiResponse<T> ServerError(string message = "服务器内部错误")
=> Fail(500, message);
}
/// <summary>
/// 无数据的统一返回格式object? 版本)。
/// </summary>
public class ApiResponse : ApiResponse<object?>
{
/// <summary>成功返回(无数据)</summary>
public static ApiResponse Succeed(string? message = null)
{
return new ApiResponse
{
Success = true,
Code = 200,
Message = message,
Data = null,
};
}
/// <summary>失败返回</summary>
public static ApiResponse Failure(int code, string message)
{
return new ApiResponse
{
Success = false,
Code = code,
Message = message,
Data = null,
};
}
}
/// <summary>
/// 分页返回格式
/// </summary>
public class PagedResponse<T>
{
/// <summary>
/// 获取或设置操作是否成功。
/// </summary>
[JsonPropertyName("success")]
public bool Success { get; set; } = true;
/// <summary>
/// 获取或设置业务状态码,默认 200。
/// </summary>
[JsonPropertyName("code")]
public int Code { get; set; } = 200;
/// <summary>
/// 获取或设置分页数据项列表。
/// </summary>
[JsonPropertyName("items")]
public List<T> Items { get; set; } = new();
/// <summary>
/// 获取或设置数据总条数。
/// </summary>
[JsonPropertyName("total")]
public int Total { get; set; }
/// <summary>
/// 获取或设置当前页码,从 1 开始。
/// </summary>
[JsonPropertyName("page")]
public int Page { get; set; } = 1;
/// <summary>
/// 获取或设置每页条数,默认 20。
/// </summary>
[JsonPropertyName("pageSize")]
public int PageSize { get; set; } = 20;
/// <summary>
/// 获取总页数(根据 Total 和 PageSize 自动计算)。
/// </summary>
[JsonPropertyName("totalPages")]
public int TotalPages => PageSize > 0 ? (int)Math.Ceiling((double)Total / PageSize) : 0;
/// <summary>
/// 从数据列表和分页参数创建分页响应。
/// </summary>
/// <param name="items">当前页数据项。</param>
/// <param name="total">数据总条数。</param>
/// <param name="page">当前页码。</param>
/// <param name="pageSize">每页条数。</param>
/// <returns>分页响应实例。</returns>
public static PagedResponse<T> From(List<T> items, int total, int page, int pageSize)
{
return new PagedResponse<T>
{
Items = items,
Total = total,
Page = page,
PageSize = pageSize,
};
}
}
/// <summary>
/// 端点返回辅助方法 —— 在 AppEndpoints 中快捷构建统一响应。
/// </summary>
public static class ResponseHelper
{
/// <summary>成功返回</summary>
public static ApiResponse<T> Ok<T>(T data, string? message = null)
=> ApiResponse<T>.Ok(data, message);
/// <summary>成功返回(无数据)</summary>
public static ApiResponse Succeed(string? message = null)
=> ApiResponse.Succeed(message);
/// <summary>失败返回</summary>
public static ApiResponse<T> Fail<T>(int code, string message, T? data = default)
=> ApiResponse<T>.Fail(code, message, data);
/// <summary>失败返回(无数据)</summary>
public static ApiResponse Failure(int code, string message)
=> ApiResponse.Failure(code, message);
/// <summary>分页返回</summary>
public static PagedResponse<T> Paged<T>(List<T> items, int total, int page, int pageSize)
=> PagedResponse<T>.From(items, total, page, pageSize);
}
}

View File

@ -0,0 +1,167 @@
using Serilog;
using Serilog.Events;
namespace Avalonia_Common.Infrastructure
{
/// <summary>
/// Serilog 日志配置 —— 可在 Avalonia-API 和 Avalonia-PC 中共享。
/// </summary>
public static class LoggingConfiguration
{
/// <summary>
/// 默认日志目录
/// </summary>
private static readonly string DefaultLogDir = Path.Combine(AppContext.BaseDirectory, "logs");
/// <summary>
/// 创建控制台日志记录器(开发环境)。
/// </summary>
public static ILogger CreateConsoleLogger(
LogEventLevel minimumLevel = LogEventLevel.Debug)
{
return new LoggerConfiguration()
.MinimumLevel.Is(minimumLevel)
.Enrich.FromLogContext()
.WriteTo.Console(
outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}")
.CreateLogger();
}
/// <summary>
/// 创建控制台 + 文件日志记录器。
/// </summary>
/// <param name="minimumLevel">最低日志级别</param>
/// <param name="logDir">日志目录,默认 ./logs</param>
/// <param name="retainedDays">保留天数</param>
public static ILogger CreateDefaultLogger(
LogEventLevel minimumLevel = LogEventLevel.Information,
string? logDir = null,
int retainedDays = 30)
{
logDir ??= DefaultLogDir;
return new LoggerConfiguration()
.MinimumLevel.Is(minimumLevel)
//.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
//.MinimumLevel.Override("System", LogEventLevel.Warning)
.Enrich.FromLogContext()
.Enrich.WithMachineName()
.Enrich.WithThreadId()
.WriteTo.Console(
outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}")
.WriteTo.File(
path: Path.Combine(logDir, "log-.txt"),
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: retainedDays,
outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff} [{Level:u3}] {Message:lj}{NewLine}{Exception}",
encoding: System.Text.Encoding.UTF8)
.CreateLogger();
}
/// <summary>
/// 创建只写文件的日志记录器(桌面应用静默模式)。
/// </summary>
public static ILogger CreateFileOnlyLogger(
LogEventLevel minimumLevel = LogEventLevel.Information,
string? logDir = null,
int retainedDays = 30)
{
logDir ??= DefaultLogDir;
return new LoggerConfiguration()
.MinimumLevel.Is(minimumLevel)
.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
.Enrich.FromLogContext()
.WriteTo.File(
path: Path.Combine(logDir, "app-.txt"),
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: retainedDays,
outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff} [{Level:u3}] {Message:lj}{NewLine}{Exception}",
encoding: System.Text.Encoding.UTF8)
.CreateLogger();
}
}
/// <summary>
/// 静态日志访问器 —— 全局静态入口,方便在没有 DI 的场景下使用。
/// </summary>
public static class AppLog
{
/// <summary>
/// 保存全局日志记录器实例。
/// </summary>
private static ILogger? _logger;
/// <summary>
/// 初始化全局日志记录器。
/// </summary>
public static void Initialize(ILogger logger)
{
_logger = logger;
Log.Logger = logger;
}
/// <summary>
/// 获取全局日志记录器。若未初始化则回退到 Serilog.Log.Logger。
/// </summary>
public static ILogger Logger => _logger ?? Log.Logger;
/// <summary>
/// 写入 Debug 级别日志。
/// </summary>
/// <param name="messageTemplate">消息模板。</param>
/// <param name="propertyValues">属性值。</param>
public static void Debug(string messageTemplate, params object?[] propertyValues)
=> Logger.Debug(messageTemplate, propertyValues);
/// <summary>
/// 写入 Information 级别日志。
/// </summary>
/// <param name="messageTemplate">消息模板。</param>
/// <param name="propertyValues">属性值。</param>
public static void Information(string messageTemplate, params object?[] propertyValues)
=> Logger.Information(messageTemplate, propertyValues);
/// <summary>
/// 写入 Warning 级别日志。
/// </summary>
/// <param name="messageTemplate">消息模板。</param>
/// <param name="propertyValues">属性值。</param>
public static void Warning(string messageTemplate, params object?[] propertyValues)
=> Logger.Warning(messageTemplate, propertyValues);
/// <summary>
/// 写入 Error 级别日志。
/// </summary>
/// <param name="messageTemplate">消息模板。</param>
/// <param name="propertyValues">属性值。</param>
public static void Error(string messageTemplate, params object?[] propertyValues)
=> Logger.Error(messageTemplate, propertyValues);
/// <summary>
/// 写入 Error 级别日志,并附带异常信息。
/// </summary>
/// <param name="exception">异常对象。</param>
/// <param name="messageTemplate">消息模板。</param>
/// <param name="propertyValues">属性值。</param>
public static void Error(Exception exception, string messageTemplate, params object?[] propertyValues)
=> Logger.Error(exception, messageTemplate, propertyValues);
/// <summary>
/// 写入 Fatal 级别日志。
/// </summary>
/// <param name="messageTemplate">消息模板。</param>
/// <param name="propertyValues">属性值。</param>
public static void Fatal(string messageTemplate, params object?[] propertyValues)
=> Logger.Fatal(messageTemplate, propertyValues);
/// <summary>
/// 写入 Fatal 级别日志,并附带异常信息。
/// </summary>
/// <param name="exception">异常对象。</param>
/// <param name="messageTemplate">消息模板。</param>
/// <param name="propertyValues">属性值。</param>
public static void Fatal(Exception exception, string messageTemplate, params object?[] propertyValues)
=> Logger.Fatal(exception, messageTemplate, propertyValues);
}
}

View File

@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<RootNamespace>Avalonia_EFCore</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.7" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
<PackageReference Include="MySql.EntityFrameworkCore" Version="10.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.7" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Avalonia-Common\Avalonia-Common.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,50 @@
using Avalonia_EFCore.Models;
using Microsoft.EntityFrameworkCore;
namespace Avalonia_EFCore.Database
{
/// <summary>
/// 应用数据库上下文 —— 继承自 Avalonia-EFCore 的 AppDbContext。
/// 所有业务实体在此注册 DbSet。
/// 这是 Avalonia-API 和 Avalonia-PC 共用的具体数据上下文。
/// </summary>
public class AppDataContext(DatabaseConfiguration dbConfig) : AppDbContext(dbConfig)
{
/// <summary>天气预报数据</summary>
public DbSet<WeatherForecastEntity> WeatherForecasts => Set<WeatherForecastEntity>();
/// <summary>用户数据</summary>
public DbSet<UserEntity> Users => Set<UserEntity>();
/// <summary>API refresh token 数据</summary>
public DbSet<ApiRefreshTokenEntity> ApiRefreshTokens => Set<ApiRefreshTokenEntity>();
/// <summary>
/// 配置实体映射,包括主键、索引和属性约束。
/// </summary>
/// <param name="modelBuilder">模型构建器。</param>
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<WeatherForecastEntity>(entity =>
{
entity.HasKey(e => e.Id).HasName("pk-weather-forecast");
entity.Property(e => e.Summary).HasMaxLength(200);
});
modelBuilder.Entity<UserEntity>(entity =>
{
entity.HasKey(e => e.Id).HasName("pk-user");
entity.Property(e => e.Email).HasMaxLength(200);
});
modelBuilder.Entity<ApiRefreshTokenEntity>(entity =>
{
entity.HasKey(e => e.Id).HasName("pk-api-refresh-token");
entity.HasIndex(e => e.TokenHash).IsUnique().HasDatabaseName("idx-api-refresh-token-hash");
entity.HasIndex(e => e.UserId).HasDatabaseName("idx-api-refresh-token-user-id");
});
}
}
}

View File

@ -0,0 +1,104 @@
using Microsoft.EntityFrameworkCore.Design;
namespace Avalonia_EFCore.Database
{
/// <summary>
/// 设计时 DbContext 工厂,用于 EF Core 迁移工具生成迁移代码。
/// </summary>
public class AppDataContextFactory : IDesignTimeDbContextFactory<AppDataContext>
{
/// <summary>
/// 创建用于设计时的 AppDataContext 实例,默认使用 SQLite 提供程序。
/// </summary>
/// <param name="args">命令行参数。</param>
/// <returns>配置好的数据上下文实例。</returns>
public AppDataContext CreateDbContext(string[] args)
{
return new AppDataContext(DesignTimeDatabaseConfiguration.Create(args));
}
}
/// <summary>
/// SQLite 迁移设计时工厂。
/// </summary>
public sealed class SqliteAppDataContextFactory : IDesignTimeDbContextFactory<SqliteAppDataContext>
{
/// <inheritdoc />
public SqliteAppDataContext CreateDbContext(string[] args)
=> new(DesignTimeDatabaseConfiguration.Create(args, DatabaseProvider.SQLite));
}
/// <summary>
/// SQL Server 迁移设计时工厂。
/// </summary>
public sealed class SqlServerAppDataContextFactory : IDesignTimeDbContextFactory<SqlServerAppDataContext>
{
/// <inheritdoc />
public SqlServerAppDataContext CreateDbContext(string[] args)
=> new(DesignTimeDatabaseConfiguration.Create(args, DatabaseProvider.SqlServer));
}
/// <summary>
/// PostgreSQL 迁移设计时工厂。
/// </summary>
public sealed class PostgreSqlAppDataContextFactory : IDesignTimeDbContextFactory<PostgreSqlAppDataContext>
{
/// <inheritdoc />
public PostgreSqlAppDataContext CreateDbContext(string[] args)
=> new(DesignTimeDatabaseConfiguration.Create(args, DatabaseProvider.PostgreSQL));
}
/// <summary>
/// MySQL 迁移设计时工厂。
/// </summary>
public sealed class MySqlAppDataContextFactory : IDesignTimeDbContextFactory<MySqlAppDataContext>
{
/// <inheritdoc />
public MySqlAppDataContext CreateDbContext(string[] args)
=> new(DesignTimeDatabaseConfiguration.Create(args, DatabaseProvider.MySQL));
}
internal static class DesignTimeDatabaseConfiguration
{
public static DatabaseConfiguration Create(string[] args, DatabaseProvider defaultProvider = DatabaseProvider.SQLite)
{
DatabaseProviderRegistry.RegisterDefaults();
var provider = GetProvider(args) ?? defaultProvider;
return provider switch
{
DatabaseProvider.SQLite => DatabaseConfiguration.ForSQLite("avalonia-api.db"),
DatabaseProvider.SqlServer => DatabaseConfiguration.ForSqlServer("(localdb)\\MSSQLLocalDB", "AvaloniaApi"),
DatabaseProvider.PostgreSQL => DatabaseConfiguration.ForPostgreSQL("localhost", "avalonia_api", "postgres", "postgres"),
DatabaseProvider.MySQL => DatabaseConfiguration.ForMySQL("localhost", "avalonia_api", "root", "root"),
_ => DatabaseConfiguration.ForSQLite("avalonia-api.db"),
};
}
private static DatabaseProvider? GetProvider(string[] args)
{
for (var i = 0; i < args.Length; i++)
{
var arg = args[i];
string? value = null;
if (arg.Equals("--provider", StringComparison.OrdinalIgnoreCase) && i + 1 < args.Length)
{
value = args[i + 1];
}
else if (arg.StartsWith("--provider=", StringComparison.OrdinalIgnoreCase))
{
value = arg["--provider=".Length..];
}
if (!string.IsNullOrWhiteSpace(value)
&& Enum.TryParse<DatabaseProvider>(value, ignoreCase: true, out var provider))
{
return provider;
}
}
return null;
}
}
}

View File

@ -0,0 +1,115 @@
using Microsoft.EntityFrameworkCore;
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace Avalonia_EFCore.Database
{
/// <summary>
/// 应用数据库上下文基类 —— 自动根据 DatabaseConfiguration 选择数据库提供程序。
/// 所有业务 DbContext 继承此类即可获得多数据库支持。
/// </summary>
public abstract class AppDbContext(DatabaseConfiguration dbConfig) : DbContext
{
/// <summary>
/// 数据库配置。
/// </summary>
private readonly DatabaseConfiguration _dbConfig = dbConfig;
/// <summary>
/// 配置数据库提供程序和连接选项。
/// </summary>
/// <param name="optionsBuilder">选项构建器。</param>
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
if (optionsBuilder.IsConfigured) return;
ConfigureProvider(optionsBuilder, _dbConfig);
if (_dbConfig.EnableDetailedLog)
{
optionsBuilder.LogTo(Console.WriteLine, Microsoft.Extensions.Logging.LogLevel.Information);
}
// 启用详细的 EF Core 错误信息
optionsBuilder.EnableDetailedErrors();
optionsBuilder.EnableSensitiveDataLogging(_dbConfig.EnableDetailedLog);
}
/// <summary>
/// 根据配置选择数据库提供程序。
/// 使用注册模式,由宿主项目注册具体的提供程序实现。
/// </summary>
public static void ConfigureProvider(DbContextOptionsBuilder optionsBuilder, DatabaseConfiguration config)
{
if (DatabaseProviderRegistry.TryGet(config.Provider, out var configurator))
{
configurator(optionsBuilder, config.ConnectionString, config.Timeout);
}
else
{
throw new NotSupportedException(
$"数据库提供程序 {config.Provider} 未注册。" +
$"请在宿主项目中安装对应的 EF Core NuGet 包并调用 DatabaseProviderRegistry.Register()。");
}
optionsBuilder.EnableDetailedErrors();
optionsBuilder.EnableSensitiveDataLogging(config.EnableDetailedLog);
if (config.EnableDetailedLog)
{
optionsBuilder.LogTo(Console.WriteLine, Microsoft.Extensions.Logging.LogLevel.Information);
}
}
/// <summary>
/// 保存时自动设置时间戳。
/// </summary>
public override int SaveChanges(bool acceptAllChangesOnSuccess)
{
SetTimestamps();
return base.SaveChanges(acceptAllChangesOnSuccess);
}
/// <summary>
/// 异步保存更改,自动设置时间戳。
/// </summary>
/// <param name="acceptAllChangesOnSuccess">是否在成功时接受所有更改。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>受影响的行数。</returns>
public override Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default)
{
SetTimestamps();
return base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
}
/// <summary>
/// 自动设置新增或修改实体的 CreatedAt 和 UpdatedAt 时间戳。
/// </summary>
private void SetTimestamps()
{
var entries = ChangeTracker.Entries()
.Where(e => e.State == EntityState.Added || e.State == EntityState.Modified);
foreach (var entry in entries)
{
var entity = entry.Entity;
// 使用反射设置 CreatedAt / UpdatedAt如果存在
var createdAtProp = entity.GetType().GetProperty("CreatedAt");
var updatedAtProp = entity.GetType().GetProperty("UpdatedAt");
if (entry.State == EntityState.Added && createdAtProp != null)
{
createdAtProp.SetValue(entity, DateTime.UtcNow);
}
if (updatedAtProp != null)
{
updatedAtProp.SetValue(entity, DateTime.UtcNow);
}
}
}
}
}

View File

@ -0,0 +1,93 @@
namespace Avalonia_EFCore.Database
{
/// <summary>
/// 支持的数据库提供程序类型。
/// </summary>
public enum DatabaseProvider
{
/// <summary>SQLite本地文件数据库无需安装跨平台</summary>
SQLite,
/// <summary>MySQL / MariaDB</summary>
MySQL,
/// <summary>PostgreSQL</summary>
PostgreSQL,
/// <summary>SQL Server</summary>
SqlServer
}
/// <summary>
/// 数据库连接配置 —— 在 appsettings.json 中配置。
/// </summary>
public class DatabaseConfiguration
{
/// <summary>数据库提供程序</summary>
public DatabaseProvider Provider { get; set; } = DatabaseProvider.SQLite;
/// <summary>连接字符串</summary>
public string ConnectionString { get; set; } = "Data Source=app.db";
/// <summary>是否在启动时自动执行迁移</summary>
public bool AutoMigrate { get; set; } = true;
/// <summary>
/// 是否在迁移前删除并重建当前连接指向的数据库。
/// 仅用于切换数据库类型或本地开发重建库;生产环境默认必须保持 false。
/// </summary>
public bool RecreateDatabase { get; set; } = false;
/// <summary>是否启用详细日志(会打印 SQL 语句)</summary>
public bool EnableDetailedLog { get; set; } = false;
/// <summary>连接超时(秒)</summary>
public int Timeout { get; set; } = 30;
// ---- 快捷构建方法 ----
/// <summary>SQLite 本地数据库</summary>
public static DatabaseConfiguration ForSQLite(string dataSource = "app.db")
{
return new DatabaseConfiguration
{
Provider = DatabaseProvider.SQLite,
ConnectionString = $"Data Source={dataSource}",
AutoMigrate = true,
};
}
/// <summary>MySQL 数据库</summary>
public static DatabaseConfiguration ForMySQL(string server, string database, string user, string password, uint port = 3306)
{
return new DatabaseConfiguration
{
Provider = DatabaseProvider.MySQL,
ConnectionString = $"Server={server};Port={port};Database={database};User={user};Password={password};",
};
}
/// <summary>PostgreSQL 数据库</summary>
public static DatabaseConfiguration ForPostgreSQL(string host, string database, string username, string password, int port = 5432)
{
return new DatabaseConfiguration
{
Provider = DatabaseProvider.PostgreSQL,
ConnectionString = $"Host={host};Port={port};Database={database};Username={username};Password={password};",
};
}
/// <summary>SQL Server 数据库</summary>
public static DatabaseConfiguration ForSqlServer(string server, string database, string? user = null, string? password = null)
{
var connStr = string.IsNullOrEmpty(user)
? $"Server={server};Database={database};Trusted_Connection=True;TrustServerCertificate=True;"
: $"Server={server};Database={database};User Id={user};Password={password};TrustServerCertificate=True;";
return new DatabaseConfiguration
{
Provider = DatabaseProvider.SqlServer,
ConnectionString = connStr,
};
}
}
}

View File

@ -0,0 +1,85 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace Avalonia_EFCore.Database
{
/// <summary>
/// 数据库服务注册扩展 —— 在 Program.cs 中一行配置数据库。
/// </summary>
public static class DatabaseExtensions
{
/// <summary>
/// 注册数据库上下文及相关服务。
/// </summary>
/// <typeparam name="TContext">继承自 AppDbContext 的业务 DbContext</typeparam>
public static IServiceCollection AddAppDatabase<TContext>(
this IServiceCollection services,
DatabaseConfiguration config)
where TContext : AppDbContext
{
// 注册配置
services.AddSingleton(config);
if (typeof(TContext) == typeof(AppDataContext))
{
services.AddProviderAppDataContext(config);
services.AddScoped<DatabaseManager<TContext>>();
return services;
}
// 注册 DbContext
services.AddDbContext<TContext>(options =>
{
AppDbContext.ConfigureProvider(options, config);
});
// 注册数据库管理器
services.AddScoped<DatabaseManager<TContext>>();
return services;
}
private static void AddProviderAppDataContext(this IServiceCollection services, DatabaseConfiguration config)
{
switch (config.Provider)
{
case DatabaseProvider.SQLite:
services.AddDbContext<AppDataContext, SqliteAppDataContext>(options =>
AppDbContext.ConfigureProvider(options, config));
break;
case DatabaseProvider.SqlServer:
services.AddDbContext<AppDataContext, SqlServerAppDataContext>(options =>
AppDbContext.ConfigureProvider(options, config));
break;
case DatabaseProvider.PostgreSQL:
services.AddDbContext<AppDataContext, PostgreSqlAppDataContext>(options =>
AppDbContext.ConfigureProvider(options, config));
break;
case DatabaseProvider.MySQL:
services.AddDbContext<AppDataContext, MySqlAppDataContext>(options =>
AppDbContext.ConfigureProvider(options, config));
break;
default:
throw new NotSupportedException($"数据库提供程序 {config.Provider} 未注册。");
}
}
/// <summary>
/// 初始化数据库(在应用启动时调用一次)。
/// </summary>
public static IServiceProvider InitializeDatabase<TContext>(
this IServiceProvider serviceProvider,
Action<TContext, IServiceProvider?>? seeder = null)
where TContext : AppDbContext
{
using var scope = serviceProvider.CreateScope();
var dbManager = scope.ServiceProvider.GetRequiredService<DatabaseManager<TContext>>();
// 同步等待初始化(启动时阻塞)
dbManager.InitializeAsync(seeder).GetAwaiter().GetResult();
return serviceProvider;
}
}
}

View File

@ -0,0 +1,224 @@
using Avalonia_Common.Infrastructure;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
namespace Avalonia_EFCore.Database
{
/// <summary>
/// 数据库管理器 —— 负责连接测试、自动迁移、种子数据、版本检查。
/// 在应用启动时调用,确保数据库结构与应用代码同步。
/// </summary>
public class DatabaseManager<TContext> where TContext : AppDbContext
{
/// <summary>
/// 数据库上下文实例。
/// </summary>
private readonly TContext _context;
/// <summary>
/// 数据库配置。
/// </summary>
private readonly DatabaseConfiguration _config;
/// <summary>
/// DI 服务提供程序(可选,用于种子数据中解析服务)。
/// </summary>
private readonly IServiceProvider? _serviceProvider;
/// <summary>
/// 初始化数据库管理器。
/// </summary>
/// <param name="context">数据库上下文。</param>
/// <param name="config">数据库配置。</param>
/// <param name="serviceProvider">可选的 DI 容器。</param>
public DatabaseManager(TContext context, DatabaseConfiguration config, IServiceProvider? serviceProvider = null)
{
_context = context;
_config = config;
_serviceProvider = serviceProvider;
}
/// <summary>
/// 初始化数据库:测试连接 → 自动迁移 → 种子数据。
/// </summary>
public async Task InitializeAsync(Action<TContext, IServiceProvider?>? seeder = null)
{
AppLog.Information(
"正在初始化数据库 Provider={Provider}, AppVersion={AppVersion}",
_config.Provider,
GetApplicationVersion());
// 1. 自动迁移如果启用。MigrateAsync 会按迁移历史顺序执行全部待处理迁移,
// 支持用户从较旧软件版本直接升级到当前版本。
if (_config.AutoMigrate)
{
if (_config.RecreateDatabase)
{
AppLog.Warning(
"RecreateDatabase=true将删除并重建当前连接指向的数据库。Provider={Provider}",
_config.Provider);
await _context.Database.EnsureDeletedAsync();
}
await MigrateAsync();
}
else
{
var canConnect = await CanConnectAsync();
if (!canConnect)
{
throw new InvalidOperationException(
$"无法连接到数据库 [{_config.Provider}],请检查连接字符串和数据库服务状态。");
}
}
// 2. 种子数据
if (seeder != null)
{
seeder(_context, _serviceProvider);
await _context.SaveChangesAsync();
}
}
/// <summary>
/// 测试数据库连接是否正常。
/// </summary>
public async Task<bool> CanConnectAsync()
{
try
{
return await _context.Database.CanConnectAsync();
}
catch
{
return false;
}
}
/// <summary>
/// 执行待处理的迁移。
/// 使用 EF Core 原生迁移机制,自动检测并应用 Schema 变更。
/// </summary>
public async Task MigrateAsync()
{
try
{
var appliedMigrations = (await _context.Database.GetAppliedMigrationsAsync()).ToList();
var pendingMigrations = await _context.Database.GetPendingMigrationsAsync();
if (pendingMigrations.Any())
{
if (appliedMigrations.Count == 0)
{
AppLog.Information(
"未检测到已应用迁移,将按当前 Provider={Provider} 从 0 构建完整表结构",
_config.Provider);
}
AppLog.Information(
"当前已应用 {AppliedCount} 个迁移,检测到 {PendingCount} 个待执行迁移: {Migrations}",
appliedMigrations.Count,
pendingMigrations.Count(),
string.Join(", ", pendingMigrations));
await _context.Database.MigrateAsync();
AppLog.Information("数据库迁移完成({Count} 个迁移已应用)", pendingMigrations.Count());
}
else
{
AppLog.Information("数据库已是最新版本,无需迁移");
}
}
catch (Exception ex)
{
AppLog.Error(ex, "数据库迁移失败");
throw;
}
}
/// <summary>
/// 获取当前应用程序的版本号,优先读取 AssemblyInformationalVersion回退到 AssemblyVersion。
/// </summary>
/// <returns>应用程序版本字符串。</returns>
private static string GetApplicationVersion()
{
var assembly = Assembly.GetEntryAssembly() ?? typeof(TContext).Assembly;
return assembly
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()
?.InformationalVersion
?? assembly.GetName().Version?.ToString()
?? "unknown";
}
/// <summary>
/// 获取数据库当前版本信息。
/// </summary>
public async Task<DatabaseVersionInfo> GetVersionInfoAsync()
{
var appliedMigrations = await _context.Database.GetAppliedMigrationsAsync();
var pendingMigrations = await _context.Database.GetPendingMigrationsAsync();
return new DatabaseVersionInfo
{
Provider = _config.Provider.ToString(),
AppliedMigrations = appliedMigrations.ToList(),
PendingMigrations = pendingMigrations.ToList(),
IsLatest = !pendingMigrations.Any(),
CanConnect = await CanConnectAsync(),
};
}
/// <summary>
/// 生成从指定迁移到最新版本的 SQL 脚本(用于生产环境审计)。
/// </summary>
public string GenerateMigrationScript(string? fromMigration = null)
{
var migrator = _context.GetService<IMigrator>();
return fromMigration is null
? migrator.GenerateScript()
: migrator.GenerateScript(fromMigration);
}
/// <summary>
/// 确保数据库已创建(不执行迁移,适用于简单场景)。
/// </summary>
public bool EnsureCreated()
{
return _context.Database.EnsureCreated();
}
}
/// <summary>
/// 数据库版本信息 DTO。
/// </summary>
public class DatabaseVersionInfo
{
/// <summary>
/// 获取或设置数据库提供程序名称。
/// </summary>
public string Provider { get; set; } = string.Empty;
/// <summary>
/// 获取或设置已应用的迁移列表。
/// </summary>
public List<string> AppliedMigrations { get; set; } = new();
/// <summary>
/// 获取或设置待应用的迁移列表。
/// </summary>
public List<string> PendingMigrations { get; set; } = new();
/// <summary>
/// 获取或设置是否为最新版本。
/// </summary>
public bool IsLatest { get; set; }
/// <summary>
/// 获取或设置数据库是否可连接。
/// </summary>
public bool CanConnect { get; set; }
}
}

View File

@ -0,0 +1,59 @@
using Microsoft.EntityFrameworkCore;
namespace Avalonia_EFCore.Database
{
/// <summary>
/// 数据库提供程序注册表 —— 统一注册所有支持的提供程序配置委托。
/// 具体使用哪个提供程序由各宿主项目决定:
/// Avalonia-API从 appsettings.json 的 DatabaseConfiguration 节读取;
/// Avalonia-PC :固定使用 SQLite。
/// </summary>
public static class DatabaseProviderRegistry
{
/// <summary>
/// 提供程序配置委托optionsBuilder, connectionString, timeout → void
/// </summary>
public delegate void ProviderConfigurator(DbContextOptionsBuilder optionsBuilder, string connectionString, int timeout);
/// <summary>
/// 保存已注册的数据库提供程序及其配置委托。
/// </summary>
private static readonly Dictionary<DatabaseProvider, ProviderConfigurator> _providers = new();
/// <summary>
/// 注册一个数据库提供程序。
/// </summary>
public static void Register(DatabaseProvider provider, ProviderConfigurator configurator)
{
_providers[provider] = configurator;
}
/// <summary>
/// 尝试获取注册的提供程序配置。
/// </summary>
public static bool TryGet(DatabaseProvider provider, out ProviderConfigurator configurator)
{
return _providers.TryGetValue(provider, out configurator!);
}
/// <summary>
/// 注册所有内置提供程序的默认配置(四个包均已内置在 Avalonia-EFCore 中)。
/// 注册完成后由调用方根据自身需求选择具体的 <see cref="DatabaseProvider"/>。
/// </summary>
public static void RegisterDefaults()
{
Register(DatabaseProvider.SQLite, (opts, cs, timeout) =>
opts.UseSqlite(cs, o => o.CommandTimeout(timeout)));
Register(DatabaseProvider.SqlServer, (opts, cs, timeout) =>
opts.UseSqlServer(cs, o => { o.CommandTimeout(timeout); o.EnableRetryOnFailure(3); }));
Register(DatabaseProvider.PostgreSQL, (opts, cs, timeout) =>
opts.UseNpgsql(cs, o => { o.CommandTimeout(timeout); o.EnableRetryOnFailure(3); }));
Register(DatabaseProvider.MySQL, (opts, cs, timeout) =>
opts.UseMySQL(cs, o => o.CommandTimeout(timeout)));
}
}
}

View File

@ -0,0 +1,30 @@
namespace Avalonia_EFCore.Database
{
/// <summary>
/// SQLite 专用 DbContext用于隔离 SQLite 迁移集。
/// </summary>
public sealed class SqliteAppDataContext(DatabaseConfiguration dbConfig) : AppDataContext(dbConfig)
{
}
/// <summary>
/// SQL Server 专用 DbContext用于隔离 SQL Server 迁移集。
/// </summary>
public sealed class SqlServerAppDataContext(DatabaseConfiguration dbConfig) : AppDataContext(dbConfig)
{
}
/// <summary>
/// PostgreSQL 专用 DbContext用于隔离 PostgreSQL 迁移集。
/// </summary>
public sealed class PostgreSqlAppDataContext(DatabaseConfiguration dbConfig) : AppDataContext(dbConfig)
{
}
/// <summary>
/// MySQL 专用 DbContext用于隔离 MySQL 迁移集。
/// </summary>
public sealed class MySqlAppDataContext(DatabaseConfiguration dbConfig) : AppDataContext(dbConfig)
{
}
}

View File

@ -0,0 +1,175 @@
// <auto-generated />
using System;
using Avalonia_EFCore.Database;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Avalonia_EFCore.Migrations.MySQL
{
[DbContext(typeof(MySqlAppDataContext))]
[Migration("20260520082626_AutoMigration_20260520162543")]
partial class AutoMigration_20260520162543
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 64);
modelBuilder.Entity("Avalonia_EFCore.Models.ApiRefreshTokenEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)")
.HasColumnName("created-at");
b.Property<string>("Device")
.HasMaxLength(200)
.HasColumnType("varchar(200)")
.HasColumnName("device");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("datetime(6)")
.HasColumnName("expires-at");
b.Property<string>("IpAddress")
.HasMaxLength(64)
.HasColumnType("varchar(64)")
.HasColumnName("ip-address");
b.Property<string>("ReplacedByTokenHash")
.HasMaxLength(128)
.HasColumnType("varchar(128)")
.HasColumnName("replaced-by-token-hash");
b.Property<DateTime?>("RevokedAt")
.HasColumnType("datetime(6)")
.HasColumnName("revoked-at");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("varchar(128)")
.HasColumnName("token-hash");
b.Property<int>("UserId")
.HasColumnType("int")
.HasColumnName("user-id");
b.HasKey("Id")
.HasName("pk-api-refresh-token");
b.HasIndex("TokenHash")
.IsUnique()
.HasDatabaseName("idx-api-refresh-token-hash");
b.HasIndex("UserId")
.HasDatabaseName("idx-api-refresh-token-user-id");
b.ToTable("api-refresh-token", t =>
{
t.HasComment("API refresh token");
});
});
modelBuilder.Entity("Avalonia_EFCore.Models.UserEntity", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasColumnName("id")
.HasComment("用户主键");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)")
.HasColumnName("created-at")
.HasComment("创建时间");
b.Property<string>("Email")
.HasMaxLength(200)
.HasColumnType("varchar(200)")
.HasColumnName("email")
.HasComment("用户邮箱");
b.Property<string>("Name")
.HasMaxLength(100)
.HasColumnType("varchar(100)")
.HasColumnName("name")
.HasComment("用户名称");
b.Property<string>("PhoneNumber")
.HasMaxLength(50)
.HasColumnType("varchar(50)")
.HasColumnName("phone-number")
.HasComment("电话号码");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)")
.HasColumnName("updated-at")
.HasComment("更新时间");
b.HasKey("Id")
.HasName("pk-user");
b.ToTable("user", t =>
{
t.HasComment("用户实体,演示数据库 CRUD 操作");
});
});
modelBuilder.Entity("Avalonia_EFCore.Models.WeatherForecastEntity", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasColumnName("id")
.HasComment("天气预报主键");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)")
.HasColumnName("created-at")
.HasComment("创建时间");
b.Property<DateOnly>("Date")
.HasColumnType("date")
.HasColumnName("date")
.HasComment("预报日期");
b.Property<string>("Summary")
.HasMaxLength(200)
.HasColumnType("varchar(200)")
.HasColumnName("summary")
.HasComment("天气摘要");
b.Property<int>("TemperatureC")
.HasColumnType("int")
.HasColumnName("temperature-c")
.HasComment("摄氏温度");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)")
.HasColumnName("updated-at")
.HasComment("更新时间");
b.HasKey("Id")
.HasName("pk-weather-forecast");
b.ToTable("weather-forecast", t =>
{
t.HasComment("天气预报数据实体");
});
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,103 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using MySql.EntityFrameworkCore.Metadata;
#nullable disable
namespace Avalonia_EFCore.Migrations.MySQL
{
/// <inheritdoc />
public partial class AutoMigration_20260520162543 : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterDatabase()
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateTable(
name: "api-refresh-token",
columns: table => new
{
id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("MySQL:ValueGenerationStrategy", MySQLValueGenerationStrategy.IdentityColumn),
userid = table.Column<int>(name: "user-id", type: "int", nullable: false),
tokenhash = table.Column<string>(name: "token-hash", type: "varchar(128)", maxLength: 128, nullable: false),
createdat = table.Column<DateTime>(name: "created-at", type: "datetime(6)", nullable: false),
expiresat = table.Column<DateTime>(name: "expires-at", type: "datetime(6)", nullable: false),
revokedat = table.Column<DateTime>(name: "revoked-at", type: "datetime(6)", nullable: true),
replacedbytokenhash = table.Column<string>(name: "replaced-by-token-hash", type: "varchar(128)", maxLength: 128, nullable: true),
device = table.Column<string>(type: "varchar(200)", maxLength: 200, nullable: true),
ipaddress = table.Column<string>(name: "ip-address", type: "varchar(64)", maxLength: 64, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk-api-refresh-token", x => x.id);
},
comment: "API refresh token")
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateTable(
name: "user",
columns: table => new
{
id = table.Column<int>(type: "int", nullable: false, comment: "用户主键")
.Annotation("MySQL:ValueGenerationStrategy", MySQLValueGenerationStrategy.IdentityColumn),
name = table.Column<string>(type: "varchar(100)", maxLength: 100, nullable: true, comment: "用户名称"),
email = table.Column<string>(type: "varchar(200)", maxLength: 200, nullable: true, comment: "用户邮箱"),
phonenumber = table.Column<string>(name: "phone-number", type: "varchar(50)", maxLength: 50, nullable: true, comment: "电话号码"),
createdat = table.Column<DateTime>(name: "created-at", type: "datetime(6)", nullable: false, comment: "创建时间"),
updatedat = table.Column<DateTime>(name: "updated-at", type: "datetime(6)", nullable: false, comment: "更新时间")
},
constraints: table =>
{
table.PrimaryKey("pk-user", x => x.id);
},
comment: "用户实体,演示数据库 CRUD 操作")
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateTable(
name: "weather-forecast",
columns: table => new
{
id = table.Column<int>(type: "int", nullable: false, comment: "天气预报主键")
.Annotation("MySQL:ValueGenerationStrategy", MySQLValueGenerationStrategy.IdentityColumn),
date = table.Column<DateOnly>(type: "date", nullable: false, comment: "预报日期"),
temperaturec = table.Column<int>(name: "temperature-c", type: "int", nullable: false, comment: "摄氏温度"),
summary = table.Column<string>(type: "varchar(200)", maxLength: 200, nullable: true, comment: "天气摘要"),
createdat = table.Column<DateTime>(name: "created-at", type: "datetime(6)", nullable: false, comment: "创建时间"),
updatedat = table.Column<DateTime>(name: "updated-at", type: "datetime(6)", nullable: false, comment: "更新时间")
},
constraints: table =>
{
table.PrimaryKey("pk-weather-forecast", x => x.id);
},
comment: "天气预报数据实体")
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateIndex(
name: "idx-api-refresh-token-hash",
table: "api-refresh-token",
column: "token-hash",
unique: true);
migrationBuilder.CreateIndex(
name: "idx-api-refresh-token-user-id",
table: "api-refresh-token",
column: "user-id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "api-refresh-token");
migrationBuilder.DropTable(
name: "user");
migrationBuilder.DropTable(
name: "weather-forecast");
}
}
}

View File

@ -0,0 +1,181 @@
// <auto-generated />
using System;
using Avalonia_EFCore.Database;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Avalonia_EFCore.Migrations.MySQL
{
[DbContext(typeof(MySqlAppDataContext))]
[Migration("20260520083306_AutoMigration_20260520163216")]
partial class AutoMigration_20260520163216
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 64);
modelBuilder.Entity("Avalonia_EFCore.Models.ApiRefreshTokenEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)")
.HasColumnName("created-at");
b.Property<string>("Device")
.HasMaxLength(200)
.HasColumnType("varchar(200)")
.HasColumnName("device");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("datetime(6)")
.HasColumnName("expires-at");
b.Property<string>("IpAddress")
.HasMaxLength(64)
.HasColumnType("varchar(64)")
.HasColumnName("ip-address");
b.Property<string>("ReplacedByTokenHash")
.HasMaxLength(128)
.HasColumnType("varchar(128)")
.HasColumnName("replaced-by-token-hash");
b.Property<DateTime?>("RevokedAt")
.HasColumnType("datetime(6)")
.HasColumnName("revoked-at");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("varchar(128)")
.HasColumnName("token-hash");
b.Property<int>("UserId")
.HasColumnType("int")
.HasColumnName("user-id");
b.HasKey("Id")
.HasName("pk-api-refresh-token");
b.HasIndex("TokenHash")
.IsUnique()
.HasDatabaseName("idx-api-refresh-token-hash");
b.HasIndex("UserId")
.HasDatabaseName("idx-api-refresh-token-user-id");
b.ToTable("api-refresh-token", t =>
{
t.HasComment("API refresh token");
});
});
modelBuilder.Entity("Avalonia_EFCore.Models.UserEntity", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasColumnName("id")
.HasComment("用户主键");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)")
.HasColumnName("created-at")
.HasComment("创建时间");
b.Property<string>("Email")
.HasMaxLength(200)
.HasColumnType("varchar(200)")
.HasColumnName("email")
.HasComment("用户邮箱");
b.Property<string>("Name")
.HasMaxLength(100)
.HasColumnType("varchar(100)")
.HasColumnName("name")
.HasComment("用户名称");
b.Property<string>("PasswordHash")
.HasMaxLength(200)
.HasColumnType("varchar(200)")
.HasColumnName("password-hash")
.HasComment("密码哈希值");
b.Property<string>("PhoneNumber")
.HasMaxLength(50)
.HasColumnType("varchar(50)")
.HasColumnName("phone-number")
.HasComment("电话号码");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)")
.HasColumnName("updated-at")
.HasComment("更新时间");
b.HasKey("Id")
.HasName("pk-user");
b.ToTable("user", t =>
{
t.HasComment("用户实体,演示数据库 CRUD 操作");
});
});
modelBuilder.Entity("Avalonia_EFCore.Models.WeatherForecastEntity", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasColumnName("id")
.HasComment("天气预报主键");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)")
.HasColumnName("created-at")
.HasComment("创建时间");
b.Property<DateOnly>("Date")
.HasColumnType("date")
.HasColumnName("date")
.HasComment("预报日期");
b.Property<string>("Summary")
.HasMaxLength(200)
.HasColumnType("varchar(200)")
.HasColumnName("summary")
.HasComment("天气摘要");
b.Property<int>("TemperatureC")
.HasColumnType("int")
.HasColumnName("temperature-c")
.HasComment("摄氏温度");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)")
.HasColumnName("updated-at")
.HasComment("更新时间");
b.HasKey("Id")
.HasName("pk-weather-forecast");
b.ToTable("weather-forecast", t =>
{
t.HasComment("天气预报数据实体");
});
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,30 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Avalonia_EFCore.Migrations.MySQL
{
/// <inheritdoc />
public partial class AutoMigration_20260520163216 : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "password-hash",
table: "user",
type: "varchar(200)",
maxLength: 200,
nullable: true,
comment: "密码哈希值");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "password-hash",
table: "user");
}
}
}

View File

@ -0,0 +1,178 @@
// <auto-generated />
using System;
using Avalonia_EFCore.Database;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Avalonia_EFCore.Migrations.MySQL
{
[DbContext(typeof(MySqlAppDataContext))]
partial class MySqlAppDataContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 64);
modelBuilder.Entity("Avalonia_EFCore.Models.ApiRefreshTokenEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)")
.HasColumnName("created-at");
b.Property<string>("Device")
.HasMaxLength(200)
.HasColumnType("varchar(200)")
.HasColumnName("device");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("datetime(6)")
.HasColumnName("expires-at");
b.Property<string>("IpAddress")
.HasMaxLength(64)
.HasColumnType("varchar(64)")
.HasColumnName("ip-address");
b.Property<string>("ReplacedByTokenHash")
.HasMaxLength(128)
.HasColumnType("varchar(128)")
.HasColumnName("replaced-by-token-hash");
b.Property<DateTime?>("RevokedAt")
.HasColumnType("datetime(6)")
.HasColumnName("revoked-at");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("varchar(128)")
.HasColumnName("token-hash");
b.Property<int>("UserId")
.HasColumnType("int")
.HasColumnName("user-id");
b.HasKey("Id")
.HasName("pk-api-refresh-token");
b.HasIndex("TokenHash")
.IsUnique()
.HasDatabaseName("idx-api-refresh-token-hash");
b.HasIndex("UserId")
.HasDatabaseName("idx-api-refresh-token-user-id");
b.ToTable("api-refresh-token", t =>
{
t.HasComment("API refresh token");
});
});
modelBuilder.Entity("Avalonia_EFCore.Models.UserEntity", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasColumnName("id")
.HasComment("用户主键");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)")
.HasColumnName("created-at")
.HasComment("创建时间");
b.Property<string>("Email")
.HasMaxLength(200)
.HasColumnType("varchar(200)")
.HasColumnName("email")
.HasComment("用户邮箱");
b.Property<string>("Name")
.HasMaxLength(100)
.HasColumnType("varchar(100)")
.HasColumnName("name")
.HasComment("用户名称");
b.Property<string>("PasswordHash")
.HasMaxLength(200)
.HasColumnType("varchar(200)")
.HasColumnName("password-hash")
.HasComment("密码哈希值");
b.Property<string>("PhoneNumber")
.HasMaxLength(50)
.HasColumnType("varchar(50)")
.HasColumnName("phone-number")
.HasComment("电话号码");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)")
.HasColumnName("updated-at")
.HasComment("更新时间");
b.HasKey("Id")
.HasName("pk-user");
b.ToTable("user", t =>
{
t.HasComment("用户实体,演示数据库 CRUD 操作");
});
});
modelBuilder.Entity("Avalonia_EFCore.Models.WeatherForecastEntity", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasColumnName("id")
.HasComment("天气预报主键");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)")
.HasColumnName("created-at")
.HasComment("创建时间");
b.Property<DateOnly>("Date")
.HasColumnType("date")
.HasColumnName("date")
.HasComment("预报日期");
b.Property<string>("Summary")
.HasMaxLength(200)
.HasColumnType("varchar(200)")
.HasColumnName("summary")
.HasComment("天气摘要");
b.Property<int>("TemperatureC")
.HasColumnType("int")
.HasColumnName("temperature-c")
.HasComment("摄氏温度");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)")
.HasColumnName("updated-at")
.HasComment("更新时间");
b.HasKey("Id")
.HasName("pk-weather-forecast");
b.ToTable("weather-forecast", t =>
{
t.HasComment("天气预报数据实体");
});
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,184 @@
// <auto-generated />
using System;
using Avalonia_EFCore.Database;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Avalonia_EFCore.Migrations.PostgreSQL
{
[DbContext(typeof(PostgreSqlAppDataContext))]
[Migration("20260520082617_AutoMigration_20260520162543")]
partial class AutoMigration_20260520162543
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Avalonia_EFCore.Models.ApiRefreshTokenEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created-at");
b.Property<string>("Device")
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("device");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expires-at");
b.Property<string>("IpAddress")
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("ip-address");
b.Property<string>("ReplacedByTokenHash")
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("replaced-by-token-hash");
b.Property<DateTime?>("RevokedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("revoked-at");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("token-hash");
b.Property<int>("UserId")
.HasColumnType("integer")
.HasColumnName("user-id");
b.HasKey("Id")
.HasName("pk-api-refresh-token");
b.HasIndex("TokenHash")
.IsUnique()
.HasDatabaseName("idx-api-refresh-token-hash");
b.HasIndex("UserId")
.HasDatabaseName("idx-api-refresh-token-user-id");
b.ToTable("api-refresh-token", t =>
{
t.HasComment("API refresh token");
});
});
modelBuilder.Entity("Avalonia_EFCore.Models.UserEntity", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id")
.HasComment("用户主键");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created-at")
.HasComment("创建时间");
b.Property<string>("Email")
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("email")
.HasComment("用户邮箱");
b.Property<string>("Name")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("name")
.HasComment("用户名称");
b.Property<string>("PhoneNumber")
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("phone-number")
.HasComment("电话号码");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated-at")
.HasComment("更新时间");
b.HasKey("Id")
.HasName("pk-user");
b.ToTable("user", t =>
{
t.HasComment("用户实体,演示数据库 CRUD 操作");
});
});
modelBuilder.Entity("Avalonia_EFCore.Models.WeatherForecastEntity", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id")
.HasComment("天气预报主键");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created-at")
.HasComment("创建时间");
b.Property<DateOnly>("Date")
.HasColumnType("date")
.HasColumnName("date")
.HasComment("预报日期");
b.Property<string>("Summary")
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("summary")
.HasComment("天气摘要");
b.Property<int>("TemperatureC")
.HasColumnType("integer")
.HasColumnName("temperature-c")
.HasComment("摄氏温度");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated-at")
.HasComment("更新时间");
b.HasKey("Id")
.HasName("pk-weather-forecast");
b.ToTable("weather-forecast", t =>
{
t.HasComment("天气预报数据实体");
});
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,97 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Avalonia_EFCore.Migrations.PostgreSQL
{
/// <inheritdoc />
public partial class AutoMigration_20260520162543 : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "api-refresh-token",
columns: table => new
{
id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
userid = table.Column<int>(name: "user-id", type: "integer", nullable: false),
tokenhash = table.Column<string>(name: "token-hash", type: "character varying(128)", maxLength: 128, nullable: false),
createdat = table.Column<DateTime>(name: "created-at", type: "timestamp with time zone", nullable: false),
expiresat = table.Column<DateTime>(name: "expires-at", type: "timestamp with time zone", nullable: false),
revokedat = table.Column<DateTime>(name: "revoked-at", type: "timestamp with time zone", nullable: true),
replacedbytokenhash = table.Column<string>(name: "replaced-by-token-hash", type: "character varying(128)", maxLength: 128, nullable: true),
device = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
ipaddress = table.Column<string>(name: "ip-address", type: "character varying(64)", maxLength: 64, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk-api-refresh-token", x => x.id);
},
comment: "API refresh token");
migrationBuilder.CreateTable(
name: "user",
columns: table => new
{
id = table.Column<int>(type: "integer", nullable: false, comment: "用户主键")
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
name = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true, comment: "用户名称"),
email = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true, comment: "用户邮箱"),
phonenumber = table.Column<string>(name: "phone-number", type: "character varying(50)", maxLength: 50, nullable: true, comment: "电话号码"),
createdat = table.Column<DateTime>(name: "created-at", type: "timestamp with time zone", nullable: false, comment: "创建时间"),
updatedat = table.Column<DateTime>(name: "updated-at", type: "timestamp with time zone", nullable: false, comment: "更新时间")
},
constraints: table =>
{
table.PrimaryKey("pk-user", x => x.id);
},
comment: "用户实体,演示数据库 CRUD 操作");
migrationBuilder.CreateTable(
name: "weather-forecast",
columns: table => new
{
id = table.Column<int>(type: "integer", nullable: false, comment: "天气预报主键")
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
date = table.Column<DateOnly>(type: "date", nullable: false, comment: "预报日期"),
temperaturec = table.Column<int>(name: "temperature-c", type: "integer", nullable: false, comment: "摄氏温度"),
summary = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true, comment: "天气摘要"),
createdat = table.Column<DateTime>(name: "created-at", type: "timestamp with time zone", nullable: false, comment: "创建时间"),
updatedat = table.Column<DateTime>(name: "updated-at", type: "timestamp with time zone", nullable: false, comment: "更新时间")
},
constraints: table =>
{
table.PrimaryKey("pk-weather-forecast", x => x.id);
},
comment: "天气预报数据实体");
migrationBuilder.CreateIndex(
name: "idx-api-refresh-token-hash",
table: "api-refresh-token",
column: "token-hash",
unique: true);
migrationBuilder.CreateIndex(
name: "idx-api-refresh-token-user-id",
table: "api-refresh-token",
column: "user-id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "api-refresh-token");
migrationBuilder.DropTable(
name: "user");
migrationBuilder.DropTable(
name: "weather-forecast");
}
}
}

View File

@ -0,0 +1,190 @@
// <auto-generated />
using System;
using Avalonia_EFCore.Database;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Avalonia_EFCore.Migrations.PostgreSQL
{
[DbContext(typeof(PostgreSqlAppDataContext))]
[Migration("20260520083254_AutoMigration_20260520163216")]
partial class AutoMigration_20260520163216
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Avalonia_EFCore.Models.ApiRefreshTokenEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created-at");
b.Property<string>("Device")
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("device");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expires-at");
b.Property<string>("IpAddress")
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("ip-address");
b.Property<string>("ReplacedByTokenHash")
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("replaced-by-token-hash");
b.Property<DateTime?>("RevokedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("revoked-at");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("token-hash");
b.Property<int>("UserId")
.HasColumnType("integer")
.HasColumnName("user-id");
b.HasKey("Id")
.HasName("pk-api-refresh-token");
b.HasIndex("TokenHash")
.IsUnique()
.HasDatabaseName("idx-api-refresh-token-hash");
b.HasIndex("UserId")
.HasDatabaseName("idx-api-refresh-token-user-id");
b.ToTable("api-refresh-token", t =>
{
t.HasComment("API refresh token");
});
});
modelBuilder.Entity("Avalonia_EFCore.Models.UserEntity", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id")
.HasComment("用户主键");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created-at")
.HasComment("创建时间");
b.Property<string>("Email")
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("email")
.HasComment("用户邮箱");
b.Property<string>("Name")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("name")
.HasComment("用户名称");
b.Property<string>("PasswordHash")
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("password-hash")
.HasComment("密码哈希值");
b.Property<string>("PhoneNumber")
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("phone-number")
.HasComment("电话号码");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated-at")
.HasComment("更新时间");
b.HasKey("Id")
.HasName("pk-user");
b.ToTable("user", t =>
{
t.HasComment("用户实体,演示数据库 CRUD 操作");
});
});
modelBuilder.Entity("Avalonia_EFCore.Models.WeatherForecastEntity", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id")
.HasComment("天气预报主键");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created-at")
.HasComment("创建时间");
b.Property<DateOnly>("Date")
.HasColumnType("date")
.HasColumnName("date")
.HasComment("预报日期");
b.Property<string>("Summary")
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("summary")
.HasComment("天气摘要");
b.Property<int>("TemperatureC")
.HasColumnType("integer")
.HasColumnName("temperature-c")
.HasComment("摄氏温度");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated-at")
.HasComment("更新时间");
b.HasKey("Id")
.HasName("pk-weather-forecast");
b.ToTable("weather-forecast", t =>
{
t.HasComment("天气预报数据实体");
});
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,30 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Avalonia_EFCore.Migrations.PostgreSQL
{
/// <inheritdoc />
public partial class AutoMigration_20260520163216 : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "password-hash",
table: "user",
type: "character varying(200)",
maxLength: 200,
nullable: true,
comment: "密码哈希值");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "password-hash",
table: "user");
}
}
}

View File

@ -0,0 +1,187 @@
// <auto-generated />
using System;
using Avalonia_EFCore.Database;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Avalonia_EFCore.Migrations.PostgreSQL
{
[DbContext(typeof(PostgreSqlAppDataContext))]
partial class PostgreSqlAppDataContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Avalonia_EFCore.Models.ApiRefreshTokenEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created-at");
b.Property<string>("Device")
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("device");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expires-at");
b.Property<string>("IpAddress")
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("ip-address");
b.Property<string>("ReplacedByTokenHash")
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("replaced-by-token-hash");
b.Property<DateTime?>("RevokedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("revoked-at");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("token-hash");
b.Property<int>("UserId")
.HasColumnType("integer")
.HasColumnName("user-id");
b.HasKey("Id")
.HasName("pk-api-refresh-token");
b.HasIndex("TokenHash")
.IsUnique()
.HasDatabaseName("idx-api-refresh-token-hash");
b.HasIndex("UserId")
.HasDatabaseName("idx-api-refresh-token-user-id");
b.ToTable("api-refresh-token", t =>
{
t.HasComment("API refresh token");
});
});
modelBuilder.Entity("Avalonia_EFCore.Models.UserEntity", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id")
.HasComment("用户主键");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created-at")
.HasComment("创建时间");
b.Property<string>("Email")
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("email")
.HasComment("用户邮箱");
b.Property<string>("Name")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("name")
.HasComment("用户名称");
b.Property<string>("PasswordHash")
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("password-hash")
.HasComment("密码哈希值");
b.Property<string>("PhoneNumber")
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("phone-number")
.HasComment("电话号码");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated-at")
.HasComment("更新时间");
b.HasKey("Id")
.HasName("pk-user");
b.ToTable("user", t =>
{
t.HasComment("用户实体,演示数据库 CRUD 操作");
});
});
modelBuilder.Entity("Avalonia_EFCore.Models.WeatherForecastEntity", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id")
.HasComment("天气预报主键");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created-at")
.HasComment("创建时间");
b.Property<DateOnly>("Date")
.HasColumnType("date")
.HasColumnName("date")
.HasComment("预报日期");
b.Property<string>("Summary")
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("summary")
.HasComment("天气摘要");
b.Property<int>("TemperatureC")
.HasColumnType("integer")
.HasColumnName("temperature-c")
.HasComment("摄氏温度");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated-at")
.HasComment("更新时间");
b.HasKey("Id")
.HasName("pk-weather-forecast");
b.ToTable("weather-forecast", t =>
{
t.HasComment("天气预报数据实体");
});
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,94 @@
using System;
using Avalonia_EFCore.Database;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Avalonia_EFCore.Migrations.SQLite
{
[DbContext(typeof(SqliteAppDataContext))]
[Migration("20260514000100_InitialCreate")]
partial class InitialCreate
{
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.0");
modelBuilder.Entity("Avalonia_EFCore.Models.UserEntity", b =>
{
b.Property<int>("Id")
.HasComment("用户主键")
.HasColumnName("id")
.ValueGeneratedOnAdd();
b.Property<DateTime>("CreatedAt")
.HasComment("创建时间")
.HasColumnName("created-at");
b.Property<string>("Email")
.HasComment("用户邮箱")
.HasColumnName("email")
.HasMaxLength(200);
b.Property<string>("Name")
.HasComment("用户名称")
.HasColumnName("name")
.HasMaxLength(100);
b.Property<DateTime>("UpdatedAt")
.HasComment("更新时间")
.HasColumnName("updated-at");
b.HasKey("Id")
.HasName("pk-user");
b.ToTable("user", t =>
{
t.HasComment("用户实体,演示数据库 CRUD 操作");
});
});
modelBuilder.Entity("Avalonia_EFCore.Models.WeatherForecastEntity", b =>
{
b.Property<int>("Id")
.HasComment("天气预报主键")
.HasColumnName("id")
.ValueGeneratedOnAdd();
b.Property<DateTime>("CreatedAt")
.HasComment("创建时间")
.HasColumnName("created-at");
b.Property<DateOnly>("Date")
.HasComment("预报日期")
.HasColumnName("date");
b.Property<string>("Summary")
.HasComment("天气摘要")
.HasColumnName("summary")
.HasMaxLength(200);
b.Property<int>("TemperatureC")
.HasComment("摄氏温度")
.HasColumnName("temperature-c");
b.Property<DateTime>("UpdatedAt")
.HasComment("更新时间")
.HasColumnName("updated-at");
b.HasKey("Id")
.HasName("pk-weather-forecast");
b.ToTable("weather-forecast", t =>
{
t.HasComment("天气预报数据实体");
});
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,62 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Avalonia_EFCore.Migrations.SQLite
{
/// <summary>
/// 初始数据库基线。后续软件版本只追加新的 Migration不修改已发布 Migration。
/// </summary>
public partial class InitialCreate : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "user",
columns: table => new
{
Id = table.Column<int>(name: "id", nullable: false, comment: "用户主键")
.Annotation("SqlServer:Identity", "1, 1")
.Annotation("Sqlite:Autoincrement", true)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Name = table.Column<string>(name: "name", maxLength: 100, nullable: true, comment: "用户名称"),
Email = table.Column<string>(name: "email", maxLength: 200, nullable: true, comment: "用户邮箱"),
CreatedAt = table.Column<DateTime>(name: "created-at", nullable: false, comment: "创建时间"),
UpdatedAt = table.Column<DateTime>(name: "updated-at", nullable: false, comment: "更新时间")
},
constraints: table =>
{
table.PrimaryKey("pk-user", x => x.Id);
},
comment: "用户实体,演示数据库 CRUD 操作");
migrationBuilder.CreateTable(
name: "weather-forecast",
columns: table => new
{
Id = table.Column<int>(name: "id", nullable: false, comment: "天气预报主键")
.Annotation("SqlServer:Identity", "1, 1")
.Annotation("Sqlite:Autoincrement", true)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Date = table.Column<DateOnly>(name: "date", nullable: false, comment: "预报日期"),
TemperatureC = table.Column<int>(name: "temperature-c", nullable: false, comment: "摄氏温度"),
Summary = table.Column<string>(name: "summary", maxLength: 200, nullable: true, comment: "天气摘要"),
CreatedAt = table.Column<DateTime>(name: "created-at", nullable: false, comment: "创建时间"),
UpdatedAt = table.Column<DateTime>(name: "updated-at", nullable: false, comment: "更新时间")
},
constraints: table =>
{
table.PrimaryKey("pk-weather-forecast", x => x.Id);
},
comment: "天气预报数据实体");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "weather-forecast");
migrationBuilder.DropTable(name: "user");
}
}
}

View File

@ -0,0 +1,113 @@
// <auto-generated />
using System;
using Avalonia_EFCore.Database;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Avalonia_EFCore.Migrations.SQLite
{
[DbContext(typeof(SqliteAppDataContext))]
[Migration("20260515072045_AutoMigration_20260515152037")]
partial class AutoMigration_20260515152037
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.0");
modelBuilder.Entity("Avalonia_EFCore.Models.UserEntity", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasColumnName("id")
.HasComment("用户主键");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created-at")
.HasComment("创建时间");
b.Property<string>("Email")
.HasMaxLength(200)
.HasColumnType("TEXT")
.HasColumnName("email")
.HasComment("用户邮箱");
b.Property<string>("Name")
.HasMaxLength(100)
.HasColumnType("TEXT")
.HasColumnName("name")
.HasComment("用户名称");
b.Property<string>("PhoneNumber")
.HasMaxLength(50)
.HasColumnType("TEXT")
.HasColumnName("phone-number")
.HasComment("电话号码");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT")
.HasColumnName("updated-at")
.HasComment("更新时间");
b.HasKey("Id")
.HasName("pk-user");
b.ToTable("user", t =>
{
t.HasComment("用户实体,演示数据库 CRUD 操作");
});
});
modelBuilder.Entity("Avalonia_EFCore.Models.WeatherForecastEntity", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasColumnName("id")
.HasComment("天气预报主键");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created-at")
.HasComment("创建时间");
b.Property<DateOnly>("Date")
.HasColumnType("TEXT")
.HasColumnName("date")
.HasComment("预报日期");
b.Property<string>("Summary")
.HasMaxLength(200)
.HasColumnType("TEXT")
.HasColumnName("summary")
.HasComment("天气摘要");
b.Property<int>("TemperatureC")
.HasColumnType("INTEGER")
.HasColumnName("temperature-c")
.HasComment("摄氏温度");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT")
.HasColumnName("updated-at")
.HasComment("更新时间");
b.HasKey("Id")
.HasName("pk-weather-forecast");
b.ToTable("weather-forecast", t =>
{
t.HasComment("天气预报数据实体");
});
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,30 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Avalonia_EFCore.Migrations.SQLite
{
/// <inheritdoc />
public partial class AutoMigration_20260515152037 : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "phone-number",
table: "user",
type: "TEXT",
maxLength: 50,
nullable: true,
comment: "电话号码");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "phone-number",
table: "user");
}
}
}

View File

@ -0,0 +1,173 @@
// <auto-generated />
using System;
using Avalonia_EFCore.Database;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Avalonia_EFCore.Migrations.SQLite
{
[DbContext(typeof(SqliteAppDataContext))]
[Migration("20260515085847_AutoMigration_20260515165835")]
partial class AutoMigration_20260515165835
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.0");
modelBuilder.Entity("Avalonia_EFCore.Models.ApiRefreshTokenEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created-at");
b.Property<string>("Device")
.HasMaxLength(200)
.HasColumnType("TEXT")
.HasColumnName("device");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("TEXT")
.HasColumnName("expires-at");
b.Property<string>("IpAddress")
.HasMaxLength(64)
.HasColumnType("TEXT")
.HasColumnName("ip-address");
b.Property<string>("ReplacedByTokenHash")
.HasMaxLength(128)
.HasColumnType("TEXT")
.HasColumnName("replaced-by-token-hash");
b.Property<DateTime?>("RevokedAt")
.HasColumnType("TEXT")
.HasColumnName("revoked-at");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT")
.HasColumnName("token-hash");
b.Property<int>("UserId")
.HasColumnType("INTEGER")
.HasColumnName("user-id");
b.HasKey("Id")
.HasName("pk-api-refresh-token");
b.HasIndex("TokenHash")
.IsUnique()
.HasDatabaseName("idx-api-refresh-token-hash");
b.HasIndex("UserId")
.HasDatabaseName("idx-api-refresh-token-user-id");
b.ToTable("api-refresh-token", t =>
{
t.HasComment("API refresh token");
});
});
modelBuilder.Entity("Avalonia_EFCore.Models.UserEntity", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasColumnName("id")
.HasComment("用户主键");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created-at")
.HasComment("创建时间");
b.Property<string>("Email")
.HasMaxLength(200)
.HasColumnType("TEXT")
.HasColumnName("email")
.HasComment("用户邮箱");
b.Property<string>("Name")
.HasMaxLength(100)
.HasColumnType("TEXT")
.HasColumnName("name")
.HasComment("用户名称");
b.Property<string>("PhoneNumber")
.HasMaxLength(50)
.HasColumnType("TEXT")
.HasColumnName("phone-number")
.HasComment("电话号码");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT")
.HasColumnName("updated-at")
.HasComment("更新时间");
b.HasKey("Id")
.HasName("pk-user");
b.ToTable("user", t =>
{
t.HasComment("用户实体,演示数据库 CRUD 操作");
});
});
modelBuilder.Entity("Avalonia_EFCore.Models.WeatherForecastEntity", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasColumnName("id")
.HasComment("天气预报主键");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created-at")
.HasComment("创建时间");
b.Property<DateOnly>("Date")
.HasColumnType("TEXT")
.HasColumnName("date")
.HasComment("预报日期");
b.Property<string>("Summary")
.HasMaxLength(200)
.HasColumnType("TEXT")
.HasColumnName("summary")
.HasComment("天气摘要");
b.Property<int>("TemperatureC")
.HasColumnType("INTEGER")
.HasColumnName("temperature-c")
.HasComment("摄氏温度");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT")
.HasColumnName("updated-at")
.HasComment("更新时间");
b.HasKey("Id")
.HasName("pk-weather-forecast");
b.ToTable("weather-forecast", t =>
{
t.HasComment("天气预报数据实体");
});
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,54 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Avalonia_EFCore.Migrations.SQLite
{
/// <inheritdoc />
public partial class AutoMigration_20260515165835 : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "api-refresh-token",
columns: table => new
{
id = table.Column<long>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
userid = table.Column<int>(name: "user-id", type: "INTEGER", nullable: false),
tokenhash = table.Column<string>(name: "token-hash", type: "TEXT", maxLength: 128, nullable: false),
createdat = table.Column<DateTime>(name: "created-at", type: "TEXT", nullable: false),
expiresat = table.Column<DateTime>(name: "expires-at", type: "TEXT", nullable: false),
revokedat = table.Column<DateTime>(name: "revoked-at", type: "TEXT", nullable: true),
replacedbytokenhash = table.Column<string>(name: "replaced-by-token-hash", type: "TEXT", maxLength: 128, nullable: true),
device = table.Column<string>(type: "TEXT", maxLength: 200, nullable: true),
ipaddress = table.Column<string>(name: "ip-address", type: "TEXT", maxLength: 64, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk-api-refresh-token", x => x.id);
},
comment: "API refresh token");
migrationBuilder.CreateIndex(
name: "idx-api-refresh-token-hash",
table: "api-refresh-token",
column: "token-hash",
unique: true);
migrationBuilder.CreateIndex(
name: "idx-api-refresh-token-user-id",
table: "api-refresh-token",
column: "user-id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "api-refresh-token");
}
}
}

View File

@ -0,0 +1,179 @@
// <auto-generated />
using System;
using Avalonia_EFCore.Database;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Avalonia_EFCore.Migrations.SQLite
{
[DbContext(typeof(SqliteAppDataContext))]
[Migration("20260520083230_AutoMigration_20260520163216")]
partial class AutoMigration_20260520163216
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.7");
modelBuilder.Entity("Avalonia_EFCore.Models.ApiRefreshTokenEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created-at");
b.Property<string>("Device")
.HasMaxLength(200)
.HasColumnType("TEXT")
.HasColumnName("device");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("TEXT")
.HasColumnName("expires-at");
b.Property<string>("IpAddress")
.HasMaxLength(64)
.HasColumnType("TEXT")
.HasColumnName("ip-address");
b.Property<string>("ReplacedByTokenHash")
.HasMaxLength(128)
.HasColumnType("TEXT")
.HasColumnName("replaced-by-token-hash");
b.Property<DateTime?>("RevokedAt")
.HasColumnType("TEXT")
.HasColumnName("revoked-at");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT")
.HasColumnName("token-hash");
b.Property<int>("UserId")
.HasColumnType("INTEGER")
.HasColumnName("user-id");
b.HasKey("Id")
.HasName("pk-api-refresh-token");
b.HasIndex("TokenHash")
.IsUnique()
.HasDatabaseName("idx-api-refresh-token-hash");
b.HasIndex("UserId")
.HasDatabaseName("idx-api-refresh-token-user-id");
b.ToTable("api-refresh-token", t =>
{
t.HasComment("API refresh token");
});
});
modelBuilder.Entity("Avalonia_EFCore.Models.UserEntity", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasColumnName("id")
.HasComment("用户主键");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created-at")
.HasComment("创建时间");
b.Property<string>("Email")
.HasMaxLength(200)
.HasColumnType("TEXT")
.HasColumnName("email")
.HasComment("用户邮箱");
b.Property<string>("Name")
.HasMaxLength(100)
.HasColumnType("TEXT")
.HasColumnName("name")
.HasComment("用户名称");
b.Property<string>("PasswordHash")
.HasMaxLength(200)
.HasColumnType("TEXT")
.HasColumnName("password-hash")
.HasComment("密码哈希值");
b.Property<string>("PhoneNumber")
.HasMaxLength(50)
.HasColumnType("TEXT")
.HasColumnName("phone-number")
.HasComment("电话号码");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT")
.HasColumnName("updated-at")
.HasComment("更新时间");
b.HasKey("Id")
.HasName("pk-user");
b.ToTable("user", t =>
{
t.HasComment("用户实体,演示数据库 CRUD 操作");
});
});
modelBuilder.Entity("Avalonia_EFCore.Models.WeatherForecastEntity", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasColumnName("id")
.HasComment("天气预报主键");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created-at")
.HasComment("创建时间");
b.Property<DateOnly>("Date")
.HasColumnType("TEXT")
.HasColumnName("date")
.HasComment("预报日期");
b.Property<string>("Summary")
.HasMaxLength(200)
.HasColumnType("TEXT")
.HasColumnName("summary")
.HasComment("天气摘要");
b.Property<int>("TemperatureC")
.HasColumnType("INTEGER")
.HasColumnName("temperature-c")
.HasComment("摄氏温度");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT")
.HasColumnName("updated-at")
.HasComment("更新时间");
b.HasKey("Id")
.HasName("pk-weather-forecast");
b.ToTable("weather-forecast", t =>
{
t.HasComment("天气预报数据实体");
});
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,30 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Avalonia_EFCore.Migrations.SQLite
{
/// <inheritdoc />
public partial class AutoMigration_20260520163216 : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "password-hash",
table: "user",
type: "TEXT",
maxLength: 200,
nullable: true,
comment: "密码哈希值");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "password-hash",
table: "user");
}
}
}

View File

@ -0,0 +1,176 @@
// <auto-generated />
using System;
using Avalonia_EFCore.Database;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Avalonia_EFCore.Migrations.SQLite
{
[DbContext(typeof(SqliteAppDataContext))]
partial class SqliteAppDataContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.7");
modelBuilder.Entity("Avalonia_EFCore.Models.ApiRefreshTokenEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created-at");
b.Property<string>("Device")
.HasMaxLength(200)
.HasColumnType("TEXT")
.HasColumnName("device");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("TEXT")
.HasColumnName("expires-at");
b.Property<string>("IpAddress")
.HasMaxLength(64)
.HasColumnType("TEXT")
.HasColumnName("ip-address");
b.Property<string>("ReplacedByTokenHash")
.HasMaxLength(128)
.HasColumnType("TEXT")
.HasColumnName("replaced-by-token-hash");
b.Property<DateTime?>("RevokedAt")
.HasColumnType("TEXT")
.HasColumnName("revoked-at");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT")
.HasColumnName("token-hash");
b.Property<int>("UserId")
.HasColumnType("INTEGER")
.HasColumnName("user-id");
b.HasKey("Id")
.HasName("pk-api-refresh-token");
b.HasIndex("TokenHash")
.IsUnique()
.HasDatabaseName("idx-api-refresh-token-hash");
b.HasIndex("UserId")
.HasDatabaseName("idx-api-refresh-token-user-id");
b.ToTable("api-refresh-token", t =>
{
t.HasComment("API refresh token");
});
});
modelBuilder.Entity("Avalonia_EFCore.Models.UserEntity", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasColumnName("id")
.HasComment("用户主键");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created-at")
.HasComment("创建时间");
b.Property<string>("Email")
.HasMaxLength(200)
.HasColumnType("TEXT")
.HasColumnName("email")
.HasComment("用户邮箱");
b.Property<string>("Name")
.HasMaxLength(100)
.HasColumnType("TEXT")
.HasColumnName("name")
.HasComment("用户名称");
b.Property<string>("PasswordHash")
.HasMaxLength(200)
.HasColumnType("TEXT")
.HasColumnName("password-hash")
.HasComment("密码哈希值");
b.Property<string>("PhoneNumber")
.HasMaxLength(50)
.HasColumnType("TEXT")
.HasColumnName("phone-number")
.HasComment("电话号码");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT")
.HasColumnName("updated-at")
.HasComment("更新时间");
b.HasKey("Id")
.HasName("pk-user");
b.ToTable("user", t =>
{
t.HasComment("用户实体,演示数据库 CRUD 操作");
});
});
modelBuilder.Entity("Avalonia_EFCore.Models.WeatherForecastEntity", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasColumnName("id")
.HasComment("天气预报主键");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created-at")
.HasComment("创建时间");
b.Property<DateOnly>("Date")
.HasColumnType("TEXT")
.HasColumnName("date")
.HasComment("预报日期");
b.Property<string>("Summary")
.HasMaxLength(200)
.HasColumnType("TEXT")
.HasColumnName("summary")
.HasComment("天气摘要");
b.Property<int>("TemperatureC")
.HasColumnType("INTEGER")
.HasColumnName("temperature-c")
.HasComment("摄氏温度");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT")
.HasColumnName("updated-at")
.HasComment("更新时间");
b.HasKey("Id")
.HasName("pk-weather-forecast");
b.ToTable("weather-forecast", t =>
{
t.HasComment("天气预报数据实体");
});
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,184 @@
// <auto-generated />
using System;
using Avalonia_EFCore.Database;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Avalonia_EFCore.Migrations.SqlServer
{
[DbContext(typeof(SqlServerAppDataContext))]
[Migration("20260520082607_AutoMigration_20260520162543")]
partial class AutoMigration_20260520162543
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("Avalonia_EFCore.Models.ApiRefreshTokenEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2")
.HasColumnName("created-at");
b.Property<string>("Device")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)")
.HasColumnName("device");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("datetime2")
.HasColumnName("expires-at");
b.Property<string>("IpAddress")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)")
.HasColumnName("ip-address");
b.Property<string>("ReplacedByTokenHash")
.HasMaxLength(128)
.HasColumnType("nvarchar(128)")
.HasColumnName("replaced-by-token-hash");
b.Property<DateTime?>("RevokedAt")
.HasColumnType("datetime2")
.HasColumnName("revoked-at");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)")
.HasColumnName("token-hash");
b.Property<int>("UserId")
.HasColumnType("int")
.HasColumnName("user-id");
b.HasKey("Id")
.HasName("pk-api-refresh-token");
b.HasIndex("TokenHash")
.IsUnique()
.HasDatabaseName("idx-api-refresh-token-hash");
b.HasIndex("UserId")
.HasDatabaseName("idx-api-refresh-token-user-id");
b.ToTable("api-refresh-token", t =>
{
t.HasComment("API refresh token");
});
});
modelBuilder.Entity("Avalonia_EFCore.Models.UserEntity", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasColumnName("id")
.HasComment("用户主键");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2")
.HasColumnName("created-at")
.HasComment("创建时间");
b.Property<string>("Email")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)")
.HasColumnName("email")
.HasComment("用户邮箱");
b.Property<string>("Name")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)")
.HasColumnName("name")
.HasComment("用户名称");
b.Property<string>("PhoneNumber")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)")
.HasColumnName("phone-number")
.HasComment("电话号码");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime2")
.HasColumnName("updated-at")
.HasComment("更新时间");
b.HasKey("Id")
.HasName("pk-user");
b.ToTable("user", t =>
{
t.HasComment("用户实体,演示数据库 CRUD 操作");
});
});
modelBuilder.Entity("Avalonia_EFCore.Models.WeatherForecastEntity", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasColumnName("id")
.HasComment("天气预报主键");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2")
.HasColumnName("created-at")
.HasComment("创建时间");
b.Property<DateOnly>("Date")
.HasColumnType("date")
.HasColumnName("date")
.HasComment("预报日期");
b.Property<string>("Summary")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)")
.HasColumnName("summary")
.HasComment("天气摘要");
b.Property<int>("TemperatureC")
.HasColumnType("int")
.HasColumnName("temperature-c")
.HasComment("摄氏温度");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime2")
.HasColumnName("updated-at")
.HasComment("更新时间");
b.HasKey("Id")
.HasName("pk-weather-forecast");
b.ToTable("weather-forecast", t =>
{
t.HasComment("天气预报数据实体");
});
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,96 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Avalonia_EFCore.Migrations.SqlServer
{
/// <inheritdoc />
public partial class AutoMigration_20260520162543 : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "api-refresh-token",
columns: table => new
{
id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
userid = table.Column<int>(name: "user-id", type: "int", nullable: false),
tokenhash = table.Column<string>(name: "token-hash", type: "nvarchar(128)", maxLength: 128, nullable: false),
createdat = table.Column<DateTime>(name: "created-at", type: "datetime2", nullable: false),
expiresat = table.Column<DateTime>(name: "expires-at", type: "datetime2", nullable: false),
revokedat = table.Column<DateTime>(name: "revoked-at", type: "datetime2", nullable: true),
replacedbytokenhash = table.Column<string>(name: "replaced-by-token-hash", type: "nvarchar(128)", maxLength: 128, nullable: true),
device = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
ipaddress = table.Column<string>(name: "ip-address", type: "nvarchar(64)", maxLength: 64, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk-api-refresh-token", x => x.id);
},
comment: "API refresh token");
migrationBuilder.CreateTable(
name: "user",
columns: table => new
{
id = table.Column<int>(type: "int", nullable: false, comment: "用户主键")
.Annotation("SqlServer:Identity", "1, 1"),
name = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true, comment: "用户名称"),
email = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true, comment: "用户邮箱"),
phonenumber = table.Column<string>(name: "phone-number", type: "nvarchar(50)", maxLength: 50, nullable: true, comment: "电话号码"),
createdat = table.Column<DateTime>(name: "created-at", type: "datetime2", nullable: false, comment: "创建时间"),
updatedat = table.Column<DateTime>(name: "updated-at", type: "datetime2", nullable: false, comment: "更新时间")
},
constraints: table =>
{
table.PrimaryKey("pk-user", x => x.id);
},
comment: "用户实体,演示数据库 CRUD 操作");
migrationBuilder.CreateTable(
name: "weather-forecast",
columns: table => new
{
id = table.Column<int>(type: "int", nullable: false, comment: "天气预报主键")
.Annotation("SqlServer:Identity", "1, 1"),
date = table.Column<DateOnly>(type: "date", nullable: false, comment: "预报日期"),
temperaturec = table.Column<int>(name: "temperature-c", type: "int", nullable: false, comment: "摄氏温度"),
summary = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true, comment: "天气摘要"),
createdat = table.Column<DateTime>(name: "created-at", type: "datetime2", nullable: false, comment: "创建时间"),
updatedat = table.Column<DateTime>(name: "updated-at", type: "datetime2", nullable: false, comment: "更新时间")
},
constraints: table =>
{
table.PrimaryKey("pk-weather-forecast", x => x.id);
},
comment: "天气预报数据实体");
migrationBuilder.CreateIndex(
name: "idx-api-refresh-token-hash",
table: "api-refresh-token",
column: "token-hash",
unique: true);
migrationBuilder.CreateIndex(
name: "idx-api-refresh-token-user-id",
table: "api-refresh-token",
column: "user-id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "api-refresh-token");
migrationBuilder.DropTable(
name: "user");
migrationBuilder.DropTable(
name: "weather-forecast");
}
}
}

View File

@ -0,0 +1,190 @@
// <auto-generated />
using System;
using Avalonia_EFCore.Database;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Avalonia_EFCore.Migrations.SqlServer
{
[DbContext(typeof(SqlServerAppDataContext))]
[Migration("20260520083242_AutoMigration_20260520163216")]
partial class AutoMigration_20260520163216
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("Avalonia_EFCore.Models.ApiRefreshTokenEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2")
.HasColumnName("created-at");
b.Property<string>("Device")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)")
.HasColumnName("device");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("datetime2")
.HasColumnName("expires-at");
b.Property<string>("IpAddress")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)")
.HasColumnName("ip-address");
b.Property<string>("ReplacedByTokenHash")
.HasMaxLength(128)
.HasColumnType("nvarchar(128)")
.HasColumnName("replaced-by-token-hash");
b.Property<DateTime?>("RevokedAt")
.HasColumnType("datetime2")
.HasColumnName("revoked-at");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)")
.HasColumnName("token-hash");
b.Property<int>("UserId")
.HasColumnType("int")
.HasColumnName("user-id");
b.HasKey("Id")
.HasName("pk-api-refresh-token");
b.HasIndex("TokenHash")
.IsUnique()
.HasDatabaseName("idx-api-refresh-token-hash");
b.HasIndex("UserId")
.HasDatabaseName("idx-api-refresh-token-user-id");
b.ToTable("api-refresh-token", t =>
{
t.HasComment("API refresh token");
});
});
modelBuilder.Entity("Avalonia_EFCore.Models.UserEntity", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasColumnName("id")
.HasComment("用户主键");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2")
.HasColumnName("created-at")
.HasComment("创建时间");
b.Property<string>("Email")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)")
.HasColumnName("email")
.HasComment("用户邮箱");
b.Property<string>("Name")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)")
.HasColumnName("name")
.HasComment("用户名称");
b.Property<string>("PasswordHash")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)")
.HasColumnName("password-hash")
.HasComment("密码哈希值");
b.Property<string>("PhoneNumber")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)")
.HasColumnName("phone-number")
.HasComment("电话号码");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime2")
.HasColumnName("updated-at")
.HasComment("更新时间");
b.HasKey("Id")
.HasName("pk-user");
b.ToTable("user", t =>
{
t.HasComment("用户实体,演示数据库 CRUD 操作");
});
});
modelBuilder.Entity("Avalonia_EFCore.Models.WeatherForecastEntity", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasColumnName("id")
.HasComment("天气预报主键");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2")
.HasColumnName("created-at")
.HasComment("创建时间");
b.Property<DateOnly>("Date")
.HasColumnType("date")
.HasColumnName("date")
.HasComment("预报日期");
b.Property<string>("Summary")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)")
.HasColumnName("summary")
.HasComment("天气摘要");
b.Property<int>("TemperatureC")
.HasColumnType("int")
.HasColumnName("temperature-c")
.HasComment("摄氏温度");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime2")
.HasColumnName("updated-at")
.HasComment("更新时间");
b.HasKey("Id")
.HasName("pk-weather-forecast");
b.ToTable("weather-forecast", t =>
{
t.HasComment("天气预报数据实体");
});
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,30 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Avalonia_EFCore.Migrations.SqlServer
{
/// <inheritdoc />
public partial class AutoMigration_20260520163216 : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "password-hash",
table: "user",
type: "nvarchar(200)",
maxLength: 200,
nullable: true,
comment: "密码哈希值");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "password-hash",
table: "user");
}
}
}

View File

@ -0,0 +1,187 @@
// <auto-generated />
using System;
using Avalonia_EFCore.Database;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Avalonia_EFCore.Migrations.SqlServer
{
[DbContext(typeof(SqlServerAppDataContext))]
partial class SqlServerAppDataContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("Avalonia_EFCore.Models.ApiRefreshTokenEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2")
.HasColumnName("created-at");
b.Property<string>("Device")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)")
.HasColumnName("device");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("datetime2")
.HasColumnName("expires-at");
b.Property<string>("IpAddress")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)")
.HasColumnName("ip-address");
b.Property<string>("ReplacedByTokenHash")
.HasMaxLength(128)
.HasColumnType("nvarchar(128)")
.HasColumnName("replaced-by-token-hash");
b.Property<DateTime?>("RevokedAt")
.HasColumnType("datetime2")
.HasColumnName("revoked-at");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)")
.HasColumnName("token-hash");
b.Property<int>("UserId")
.HasColumnType("int")
.HasColumnName("user-id");
b.HasKey("Id")
.HasName("pk-api-refresh-token");
b.HasIndex("TokenHash")
.IsUnique()
.HasDatabaseName("idx-api-refresh-token-hash");
b.HasIndex("UserId")
.HasDatabaseName("idx-api-refresh-token-user-id");
b.ToTable("api-refresh-token", t =>
{
t.HasComment("API refresh token");
});
});
modelBuilder.Entity("Avalonia_EFCore.Models.UserEntity", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasColumnName("id")
.HasComment("用户主键");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2")
.HasColumnName("created-at")
.HasComment("创建时间");
b.Property<string>("Email")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)")
.HasColumnName("email")
.HasComment("用户邮箱");
b.Property<string>("Name")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)")
.HasColumnName("name")
.HasComment("用户名称");
b.Property<string>("PasswordHash")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)")
.HasColumnName("password-hash")
.HasComment("密码哈希值");
b.Property<string>("PhoneNumber")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)")
.HasColumnName("phone-number")
.HasComment("电话号码");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime2")
.HasColumnName("updated-at")
.HasComment("更新时间");
b.HasKey("Id")
.HasName("pk-user");
b.ToTable("user", t =>
{
t.HasComment("用户实体,演示数据库 CRUD 操作");
});
});
modelBuilder.Entity("Avalonia_EFCore.Models.WeatherForecastEntity", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasColumnName("id")
.HasComment("天气预报主键");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2")
.HasColumnName("created-at")
.HasComment("创建时间");
b.Property<DateOnly>("Date")
.HasColumnType("date")
.HasColumnName("date")
.HasComment("预报日期");
b.Property<string>("Summary")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)")
.HasColumnName("summary")
.HasComment("天气摘要");
b.Property<int>("TemperatureC")
.HasColumnType("int")
.HasColumnName("temperature-c")
.HasComment("摄氏温度");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime2")
.HasColumnName("updated-at")
.HasComment("更新时间");
b.HasKey("Id")
.HasName("pk-weather-forecast");
b.ToTable("weather-forecast", t =>
{
t.HasComment("天气预报数据实体");
});
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,79 @@
using Microsoft.EntityFrameworkCore;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Avalonia_EFCore.Models
{
/// <summary>
/// API refresh token。只保存哈希不保存明文 token。
/// </summary>
[Comment("API refresh token")]
[Table("api-refresh-token")]
public class ApiRefreshTokenEntity
{
/// <summary>
/// 获取或设置主键 ID自增
/// </summary>
[Key]
[Column("id")]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public long Id { get; set; }
/// <summary>
/// 获取或设置关联的用户 ID。
/// </summary>
[Column("user-id")]
public int UserId { get; set; }
/// <summary>
/// 获取或设置 Token 的 SHA256 哈希值,用于安全存储和查询。
/// </summary>
[Column("token-hash")]
[MaxLength(128)]
public string TokenHash { get; set; } = string.Empty;
/// <summary>
/// 获取或设置 Token 创建时间。
/// </summary>
[Column("created-at")]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
/// <summary>
/// 获取或设置 Token 过期时间。
/// </summary>
[Column("expires-at")]
public DateTime ExpiresAt { get; set; }
/// <summary>
/// 获取或设置 Token 撤销时间null 表示尚未撤销。
/// </summary>
[Column("revoked-at")]
public DateTime? RevokedAt { get; set; }
/// <summary>
/// 获取或设置替换此 Token 的新 Token 哈希值(轮换时设置)。
/// </summary>
[Column("replaced-by-token-hash")]
[MaxLength(128)]
public string? ReplacedByTokenHash { get; set; }
/// <summary>
/// 获取或设置创建设备标识(如 User-Agent
/// </summary>
[Column("device")]
[MaxLength(200)]
public string? Device { get; set; }
/// <summary>
/// 获取或设置创建时的客户端 IP 地址。
/// </summary>
[Column("ip-address")]
[MaxLength(64)]
public string? IpAddress { get; set; }
/// <summary>
/// 获取 Token 是否有效(未被撤销且未过期)。
/// </summary>
public bool IsActive => RevokedAt is null && ExpiresAt > DateTime.UtcNow;
}
}

View File

@ -0,0 +1,69 @@
using Microsoft.EntityFrameworkCore;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Avalonia_EFCore.Models
{
/// <summary>
/// 用户实体 —— 演示数据库 CRUD 操作。
/// </summary>
[Comment("用户实体,演示数据库 CRUD 操作")]
[Table("user")]
public class UserEntity
{
/// <summary>
/// 获取或设置用户主键 ID自增
/// </summary>
[Key]
[Comment("用户主键")]
[Column("id")]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public int Id { get; set; }
/// <summary>
/// 获取或设置用户名称。
/// </summary>
[Comment("用户名称")]
[Column("name")]
[MaxLength(100)]
public string? Name { get; set; }
/// <summary>
/// 获取或设置用户密码哈希值。
/// </summary>
[Comment("密码哈希值")]
[Column("password-hash")]
[MaxLength(200)]
public string? PasswordHash { get; set; }
/// <summary>
/// 获取或设置用户邮箱。
/// </summary>
[Comment("用户邮箱")]
[Column("email")]
[MaxLength(200)]
public string? Email { get; set; }
/// <summary>
/// 获取或设置用户电话号码。
/// </summary>
[Comment("电话号码")]
[Column("phone-number")]
[MaxLength(50)]
public string? PhoneNumber { get; set; }
/// <summary>
/// 获取或设置用户创建时间。
/// </summary>
[Comment("创建时间")]
[Column("created-at")]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
/// <summary>
/// 获取或设置用户最后更新时间。
/// </summary>
[Comment("更新时间")]
[Column("updated-at")]
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
}

View File

@ -0,0 +1,28 @@
namespace Avalonia_EFCore.Models
{
/// <summary>
/// 天气预报数据模型(内存/DTO 用,非数据库实体)。
/// </summary>
public class WeatherForecast
{
/// <summary>
/// 获取或设置预报日期。
/// </summary>
public DateOnly Date { get; set; }
/// <summary>
/// 获取或设置摄氏温度。
/// </summary>
public int TemperatureC { get; set; }
/// <summary>
/// 获取华氏温度(根据摄氏温度自动计算)。
/// </summary>
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
/// <summary>
/// 获取或设置天气摘要。
/// </summary>
public string? Summary { get; set; }
}
}

View File

@ -0,0 +1,59 @@
using Microsoft.EntityFrameworkCore;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Avalonia_EFCore.Models
{
/// <summary>
/// 天气预报数据实体。
/// </summary>
[Comment("天气预报数据实体")]
[Table("weather-forecast")]
public class WeatherForecastEntity
{
/// <summary>
/// 获取或设置天气预报主键 ID自增
/// </summary>
[Key]
[Comment("天气预报主键")]
[Column("id")]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public int Id { get; set; }
/// <summary>
/// 获取或设置预报日期。
/// </summary>
[Comment("预报日期")]
[Column("date")]
public DateOnly Date { get; set; }
/// <summary>
/// 获取或设置摄氏温度。
/// </summary>
[Comment("摄氏温度")]
[Column("temperature-c")]
public int TemperatureC { get; set; }
/// <summary>
/// 获取或设置天气摘要。
/// </summary>
[Comment("天气摘要")]
[Column("summary")]
[MaxLength(200)]
public string? Summary { get; set; }
/// <summary>
/// 获取或设置记录创建时间。
/// </summary>
[Comment("创建时间")]
[Column("created-at")]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
/// <summary>
/// 获取或设置记录最后更新时间。
/// </summary>
[Comment("更新时间")]
[Column("updated-at")]
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
}

View File

@ -0,0 +1,4 @@
# Copilot Instructions
## 项目指南
- 用户偏好:仅修改明确要求的内容,不要做额外改动(如未请求的 ViewModel DI 注册)。

15
Avalonia-PC/App.axaml Normal file
View File

@ -0,0 +1,15 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="Avalonia_PC.App"
xmlns:local="using:Avalonia_PC"
RequestedThemeVariant="Default">
<!-- "Default" ThemeVariant follows system theme variant. "Dark" or "Light" are other available options. -->
<Application.DataTemplates>
<local:ViewLocator/>
</Application.DataTemplates>
<Application.Styles>
<FluentTheme />
</Application.Styles>
</Application>

37
Avalonia-PC/App.axaml.cs Normal file
View File

@ -0,0 +1,37 @@
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using Avalonia_PC.ViewModels;
using Avalonia_PC.Views;
using Microsoft.Extensions.DependencyInjection;
namespace Avalonia_PC
{
/// <summary>
/// Avalonia 应用程序入口类,负责初始化 XAML 资源和设置主窗口。
/// </summary>
public partial class App : Application
{
/// <summary>
/// 加载 Avalonia XAML 资源。
/// </summary>
public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
}
/// <summary>
/// 框架初始化完成后设置主窗口和数据上下文。
/// </summary>
public override void OnFrameworkInitializationCompleted()
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
desktop.MainWindow = Program.Services.GetRequiredService<MainWindow>();
desktop.MainWindow.DataContext = new MainWindowViewModel();
}
base.OnFrameworkInitializationCompleted();
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

View File

@ -0,0 +1,50 @@
using Avalonia_Services.Services.AuthService;
using System;
using System.Threading;
using System.Threading.Tasks;
namespace Avalonia_PC.Authentication
{
/// <summary>
/// 第三方授权客户端占位实现。接入真实第三方接口时替换此服务即可。
/// </summary>
public sealed class DefaultPcThirdPartyAuthorizationClient : IPcThirdPartyAuthorizationClient
{
/// <summary>
/// 验证第三方授权码是否有效。默认实现将 "invalid" 视为授权丢失,其余视为有效。
/// </summary>
/// <param name="authorizationCode">第三方授权码。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>授权检查结果。</returns>
public Task<ThirdPartyAuthCheckResult> ValidateAuthorizationCodeAsync(
string authorizationCode,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(authorizationCode) ||
string.Equals(authorizationCode, "invalid", StringComparison.OrdinalIgnoreCase))
{
return Task.FromResult(ThirdPartyAuthCheckResult.AuthorizationLost);
}
return Task.FromResult(ThirdPartyAuthCheckResult.Valid);
}
/// <summary>
/// 刷新第三方授权。默认实现总是返回 TemporaryFailure表示暂时无法刷新。
/// </summary>
/// <param name="authorizationReference">授权引用标识。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>授权检查结果。</returns>
public Task<ThirdPartyAuthCheckResult> RefreshAuthorizationAsync(
string authorizationReference,
CancellationToken cancellationToken = default)
{
if (string.Equals(authorizationReference, "invalid", StringComparison.OrdinalIgnoreCase))
{
return Task.FromResult(ThirdPartyAuthCheckResult.AuthorizationLost);
}
return Task.FromResult(ThirdPartyAuthCheckResult.TemporaryFailure);
}
}
}

View File

@ -0,0 +1,94 @@
using Authentication;
using Avalonia_Common.Core;
using Avalonia_Services.Core;
using Avalonia_Services.Services.AuthService;
using System;
using System.Text.Json;
using System.Threading.Tasks;
namespace Avalonia_PC.Authentication
{
/// <summary>
/// PC 端鉴权端点服务,实现 <see cref="IPcAuthEndpointService"/>
/// 处理授权码登录、Token 刷新和登出操作。
/// </summary>
public sealed class PcAuthEndpointService(PcGlobalTokenService tokenService) : IPcAuthEndpointService
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
};
/// <inheritdoc />
public async Task<object?> AuthorizeAsync(ServiceEndpointContext ctx)
{
var request = Deserialize<PcAuthorizeRequest>(ctx.Body);
var token = await tokenService.AuthorizeAsync(request?.AuthorizationCode);
if (token is null)
{
ctx.StatusCode = 401;
return ResponseHelper.Failure(401, "授权失败");
}
return ResponseHelper.Ok(token, "授权成功");
}
/// <inheritdoc />
public async Task<object?> RefreshAsync(ServiceEndpointContext ctx)
{
var request = Deserialize<PcRefreshRequest>(ctx.Body);
var token = request?.Token ?? ExtractBearerToken(ctx.GetHeader("Authorization"));
var refreshed = await tokenService.RefreshAsync(token);
if (refreshed is null)
{
ctx.StatusCode = 401;
return ResponseHelper.Failure(401, "授权已失效");
}
return ResponseHelper.Ok(refreshed, "刷新成功");
}
/// <inheritdoc />
public Task<object?> LogoutAsync(ServiceEndpointContext ctx)
{
var request = Deserialize<PcLogoutRequest>(ctx.Body);
var token = request?.Token ?? ExtractBearerToken(ctx.GetHeader("Authorization"));
tokenService.Logout(token);
return Task.FromResult<object?>(ResponseHelper.Succeed("退出成功"));
}
/// <summary>
/// 将 JSON 请求体反序列化为指定类型。
/// </summary>
/// <typeparam name="T">目标类型。</typeparam>
/// <param name="body">JSON 请求体字符串,可为空。</param>
/// <returns>反序列化后的对象;若 body 为空则返回默认值。</returns>
private static T? Deserialize<T>(string? body)
{
return string.IsNullOrWhiteSpace(body)
? default
: JsonSerializer.Deserialize<T>(body, JsonOptions);
}
/// <summary>
/// 从 Authorization 头中提取 Bearer Token。
/// </summary>
/// <param name="authorization">Authorization 头的值。</param>
/// <returns>提取的 Token 字符串;若无法提取则返回 null。</returns>
private static string? ExtractBearerToken(string? authorization)
{
if (string.IsNullOrWhiteSpace(authorization))
{
return null;
}
/// <summary>
/// Bearer Token 的前缀常量。
/// </summary>
const string prefix = "Bearer ";
return authorization.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)
? authorization[prefix.Length..].Trim()
: authorization.Trim();
}
}
}

View File

@ -0,0 +1,60 @@
using Authentication;
using Avalonia_Services.Core;
using System;
using System.Security.Claims;
using System.Threading.Tasks;
namespace Avalonia_PC.Authentication
{
/// <summary>
/// PC 端鉴权服务,基于全局 Token 验证用户身份,实现 <see cref="IAuthService"/>。
/// </summary>
public sealed class PcAuthService(PcGlobalTokenService tokenService) : IAuthService
{
/// <inheritdoc />
public async Task<ClaimsPrincipal?> AuthenticateAsync(ServiceEndpointContext context)
{
var token = ExtractBearerToken(context.GetHeader("Authorization"));
if (!await tokenService.ValidateAsync(token))
{
return null;
}
var identity = new ClaimsIdentity(
[
new Claim(ClaimTypes.NameIdentifier, "pc-local"),
new Claim(ClaimTypes.Name, "PC授权用户"),
new Claim(ClaimTypes.Role, "SuperAdmin"),
new Claim(ClaimTypes.Role, "Admin"),
new Claim("auth_type", "pc-global-token"),
],
"pc-global-token");
return new ClaimsPrincipal(identity);
}
/// <inheritdoc />
public Task<bool> AuthorizeAsync(ClaimsPrincipal user, string policy)
{
return Task.FromResult(user.Identity?.IsAuthenticated == true);
}
/// <summary>
/// 从 Authorization 头中提取 Bearer Token。
/// </summary>
/// <param name="authorization">Authorization 头的值。</param>
/// <returns>提取的 Token 字符串;若无法提取则返回 null。</returns>
private static string? ExtractBearerToken(string? authorization)
{
if (string.IsNullOrWhiteSpace(authorization))
{
return null;
}
string prefix = "Bearer ";
return authorization.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)
? authorization[prefix.Length..].Trim()
: authorization.Trim();
}
}
}

View File

@ -0,0 +1,227 @@
using Avalonia_Services.Services.AuthService;
using System;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace Authentication
{
/// <summary>
/// PC 端全局 Token 服务,管理全局唯一的访问 Token。
/// 支持授权码登录、Token 刷新、有效性验证和登出,
/// 在第三方授权暂时失败时使用缩短有效期的临时 Token。
/// </summary>
public sealed class PcGlobalTokenService(IPcThirdPartyAuthorizationClient thirdPartyClient)
{
/// <summary>
/// 超级管理员角色集合。
/// </summary>
private static readonly string[] SuperRoles = ["SuperAdmin", "Admin"];
/// <summary>
/// 线程同步锁。
/// </summary>
private readonly object _syncRoot = new();
/// <summary>
/// 当前 Token 状态。
/// </summary>
private PcTokenState? _current;
/// <summary>
/// 正常 Token 有效期8 小时)。
/// </summary>
private static readonly TimeSpan NormalLifetime = TimeSpan.FromHours(8);
/// <summary>
/// 第三方暂时失败时的 Token 有效期20 分钟)。
/// </summary>
private static readonly TimeSpan TemporaryFailureLifetime = TimeSpan.FromMinutes(20);
/// <summary>
/// 第三方暂时失败的最长容忍窗口24 小时),超出后清除 Token。
/// </summary>
private static readonly TimeSpan MaxTemporaryFailureWindow = TimeSpan.FromHours(24);
/// <summary>
/// 使用授权码进行登录授权,验证成功后颁发全局 Token。
/// </summary>
/// <param name="authorizationCode">第三方授权码。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>Token 响应;若授权码无效则返回 null。</returns>
public async Task<PcTokenResponse?> AuthorizeAsync(string? authorizationCode, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(authorizationCode))
{
return null;
}
var result = await thirdPartyClient.ValidateAuthorizationCodeAsync(authorizationCode, cancellationToken);
if (result != ThirdPartyAuthCheckResult.Valid)
{
return null;
}
return IssueToken(authorizationCode, NormalLifetime, resetTemporaryFailureWindow: true);
}
/// <summary>
/// 刷新当前 Token向第三方验证授权引用是否仍然有效。
/// 根据第三方返回结果决定是续期、降级为临时 Token 还是清除。
/// </summary>
/// <param name="token">当前 Token。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>新的 Token 响应;若授权丢失则返回 null。</returns>
public async Task<PcTokenResponse?> RefreshAsync(string? token, CancellationToken cancellationToken = default)
{
PcTokenState? current;
lock (_syncRoot)
{
current = IsCurrentToken(token) ? _current : null;
}
if (current is null)
{
return null;
}
var result = await thirdPartyClient.RefreshAuthorizationAsync(current.AuthorizationReference, cancellationToken);
return result switch
{
ThirdPartyAuthCheckResult.Valid => IssueToken(current.AuthorizationReference, NormalLifetime, resetTemporaryFailureWindow: true),
ThirdPartyAuthCheckResult.AuthorizationLost => ClearAndReturnNull(),
ThirdPartyAuthCheckResult.TemporaryFailure => RefreshAfterTemporaryFailure(current),
_ => null,
};
}
/// <summary>
/// 验证 Token 是否有效,若已过期则尝试自动刷新。
/// </summary>
/// <param name="token">要验证的 Token。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>Token 是否有效。</returns>
public async Task<bool> ValidateAsync(string? token, CancellationToken cancellationToken = default)
{
PcTokenState? current;
lock (_syncRoot)
{
if (!IsCurrentToken(token))
{
return false;
}
current = _current;
if (current is not null && current.ExpiresAt > DateTime.UtcNow)
{
return true;
}
}
return await RefreshAsync(token, cancellationToken) is not null;
}
/// <summary>
/// 登出并清除当前 Token。
/// </summary>
/// <param name="token">要清除的 Token。</param>
public void Logout(string? token)
{
lock (_syncRoot)
{
if (IsCurrentToken(token))
{
_current = null;
}
}
}
/// <summary>
/// 颁发新的全局 Token。
/// </summary>
/// <param name="authorizationReference">授权引用标识。</param>
/// <param name="lifetime">Token 有效期。</param>
/// <param name="resetTemporaryFailureWindow">是否重置暂时失败窗口。</param>
/// <returns>包含 Token 和过期时间的响应。</returns>
private PcTokenResponse IssueToken(string authorizationReference, TimeSpan lifetime, bool resetTemporaryFailureWindow)
{
var token = Convert.ToBase64String(RandomNumberGenerator.GetBytes(64));
var now = DateTime.UtcNow;
var state = new PcTokenState(
HashToken(token),
authorizationReference,
now.Add(lifetime),
resetTemporaryFailureWindow ? null : _current?.TemporaryFailureStartedAt ?? now);
lock (_syncRoot)
{
_current = state;
}
return new PcTokenResponse(token, state.ExpiresAt, SuperRoles);
}
/// <summary>
/// 在第三方暂时失败时刷新 Token。若超出最大容忍窗口则清除 Token。
/// </summary>
/// <param name="current">当前 Token 状态。</param>
/// <returns>新的临时 Token 响应;若超出容忍窗口则返回 null。</returns>
private PcTokenResponse? RefreshAfterTemporaryFailure(PcTokenState current)
{
var startedAt = current.TemporaryFailureStartedAt ?? DateTime.UtcNow;
if (DateTime.UtcNow - startedAt > MaxTemporaryFailureWindow)
{
return ClearAndReturnNull();
}
return IssueToken(current.AuthorizationReference, TemporaryFailureLifetime, resetTemporaryFailureWindow: false);
}
/// <summary>
/// 清除当前 Token 并返回 null。
/// </summary>
/// <returns>始终返回 null。</returns>
private PcTokenResponse? ClearAndReturnNull()
{
lock (_syncRoot)
{
_current = null;
}
return null;
}
/// <summary>
/// 检查给定 Token 是否与当前持有的 Token 匹配。
/// </summary>
/// <param name="token">要检查的 Token。</param>
/// <returns>是否匹配。</returns>
private bool IsCurrentToken(string? token)
{
return !string.IsNullOrWhiteSpace(token) &&
_current is not null &&
string.Equals(_current.TokenHash, HashToken(token), StringComparison.Ordinal);
}
/// <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);
}
/// <summary>
/// 保存当前全局 Token 的内部状态。
/// </summary>
/// <param name="TokenHash">Token 的 SHA256 哈希值。</param>
/// <param name="AuthorizationReference">授权引用标识。</param>
/// <param name="ExpiresAt">过期时间。</param>
/// <param name="TemporaryFailureStartedAt">第三方暂时失败的起始时间。</param>
private sealed record PcTokenState(
string TokenHash,
string AuthorizationReference,
DateTime ExpiresAt,
DateTime? TemporaryFailureStartedAt);
}
}

View File

@ -0,0 +1,48 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ApplicationManifest>app.manifest</ApplicationManifest>
<ApplicationIcon>Assets\avalonia-logo.ico</ApplicationIcon>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
</PropertyGroup>
<ItemGroup>
<AvaloniaResource Include="Assets\**" />
<Content Include="www\**\*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
<ItemGroup>
<AvaloniaXaml Remove="Models\**" />
<Compile Remove="Models\**" />
<EmbeddedResource Remove="Models\**" />
<None Remove="Models\**" />
</ItemGroup>
<ItemGroup>
<None Include=".github\copilot-instructions.md" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="12.0.1" />
<PackageReference Include="Avalonia.Desktop" Version="12.0.1" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.0.1" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="12.0.1" />
<PackageReference Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.1">
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
</PackageReference>
<PackageReference Include="Avalonia.Controls.WebView" Version="12.0.0" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.7" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Avalonia-Services\Avalonia-Services.csproj" />
<ProjectReference Include="..\Avalonia-Common\Avalonia-Common.csproj" />
<ProjectReference Include="..\Avalonia-EFCore\Avalonia-EFCore.csproj" />
</ItemGroup>
</Project>

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 Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
<DebuggerFlavor>ProjectDebugger</DebuggerFlavor>
</PropertyGroup>
<PropertyGroup>
<ActiveDebugProfile>Avalonia-PC</ActiveDebugProfile>
</PropertyGroup>
</Project>

View File

@ -0,0 +1,11 @@
<Solution>
<Project Path="../Avalonia-API/Avalonia-API.csproj" Id="e33aba9a-a56b-4f6b-8eaa-3acbed65ebad" />
<Project Path="../Avalonia-Common/Avalonia-Common.csproj" Id="caed4118-2161-4382-90b8-35fb4efe3b5f" />
<Project Path="../Avalonia-EFCore/Avalonia-EFCore.csproj" Id="64557501-62a7-4863-b2bf-1570b8c6fecb" />
<Project Path="../Avalonia-Services/Avalonia-Services.csproj" Id="b8757cf9-5422-4c67-acae-3c967c95f866" />
<Project Path="../Avalonia-Web-VUE/avalonia-web-vue.esproj">
<Build />
<Deploy />
</Project>
<Project Path="Avalonia-PC.csproj" />
</Solution>

97
Avalonia-PC/Program.cs Normal file
View File

@ -0,0 +1,97 @@
using Authentication;
using Avalonia;
using Avalonia_Common.Infrastructure;
using Avalonia_EFCore.Database;
using Avalonia_PC.Authentication;
using Avalonia_PC.Views;
using Avalonia_Services.Core;
using Avalonia_Services.Endpoints;
using Avalonia_Services.Services;
using Avalonia_Services.Services.AuthService;
using Microsoft.Extensions.DependencyInjection;
using Serilog;
using System;
namespace Avalonia_PC
{
/// <summary>
/// 桌面应用程序入口类,负责配置 DI 容器、初始化数据库和启动 Avalonia 框架。
/// </summary>
internal sealed class Program
{
/// <summary>
/// 获取全局 DI 服务提供程序。
/// </summary>
public static IServiceProvider Services { get; private set; } = null!;
/// <summary>
/// 应用程序主入口点。
/// </summary>
/// <param name="args">命令行参数。</param>
[STAThread]
public static void Main(string[] args)
{
// 初始化日志系统
AppLog.Initialize(LoggingConfiguration.CreateDefaultLogger(logDir: "logs"));
AppLog.Information("Avalonia-PC 正在启动...");
ConfigureServices();
// 初始化数据库(自动迁移 + 种子数据)
Services.InitializeDatabase<AppDataContext>();
#if DEBUG
// 开启 WebView2 远程调试,启动后在 Edge 中访问 edge://inspect 调试网页
Environment.SetEnvironmentVariable(
"WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS",
"--remote-debugging-port=9222 --auto-open-devtools-for-tabs");
#endif
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
}
/// <summary>
/// 配置 DI 容器,注册数据库、业务服务、鉴权服务和统一端点。
/// </summary>
private static void ConfigureServices()
{
var services = new ServiceCollection();
// ---- 数据库 ----
// 注册默认数据库提供程序SQLite / MySQL / PostgreSQL / SqlServer
DatabaseProviderRegistry.RegisterDefaults();
// 桌面端固定使用 SQLite 本地数据库
services.AddAppDatabase<AppDataContext>(DatabaseConfiguration.ForSQLite("app.db"));
// ---- 业务服务 ----
services.AddSingleton<WeatherForecastService>();
services.AddSingleton<IPcThirdPartyAuthorizationClient, DefaultPcThirdPartyAuthorizationClient>();
services.AddSingleton<PcGlobalTokenService>();
services.AddSingleton<IAuthService, PcAuthService>();
services.AddSingleton<IPcAuthEndpointService, PcAuthEndpointService>();
// ---- 端点注册 ----
var endpointBuilder = new ServiceEndpointBuilder();
AppEndpoints.Configure(endpointBuilder);
AuthEndpoints.ConfigurePc(endpointBuilder);
var endpoints = endpointBuilder.Build();
services.AddSingleton(endpoints);
// 注册 Window
services.AddTransient<MainWindow>(sp => new MainWindow(sp));
Services = services.BuildServiceProvider();
}
/// <summary>
/// 构建 Avalonia 应用程序(供可视化设计器使用,请勿删除)。
/// </summary>
/// <returns>Avalonia 应用构建器。</returns>
public static AppBuilder BuildAvaloniaApp()
=> AppBuilder.Configure<App>()
.UsePlatformDetect()
.WithInterFont()
.LogToTrace();
}
}

View File

@ -0,0 +1,11 @@
{
"profiles": {
"Avalonia-PC": {
"commandName": "Project"
},
"WSL": {
"commandName": "WSL2",
"distributionName": ""
}
}
}

View File

@ -0,0 +1,53 @@
using Avalonia.Controls;
using Avalonia.Controls.Templates;
using Avalonia_PC.ViewModels;
using System;
using System.Diagnostics.CodeAnalysis;
namespace Avalonia_PC
{
/// <summary>
/// Given a view model, returns the corresponding view if possible.
/// </summary>
[RequiresUnreferencedCode(
"Default implementation of ViewLocator involves reflection which may be trimmed away.",
Url = "https://docs.avaloniaui.net/docs/concepts/view-locator")]
/// <summary>
/// 视图定位器,根据 ViewModel 类型自动查找对应的 View
/// 实现 IDataTemplate 以支持 Avalonia 的数据模板机制。
/// </summary>
public class ViewLocator : IDataTemplate
{
/// <summary>
/// 根据 ViewModel 实例构建对应的 View 控件。
/// 约定:将 ViewModels 命名空间中的 ViewModel 替换为 Views 命名空间中的同名 View。
/// </summary>
/// <param name="param">ViewModel 实例。</param>
/// <returns>对应的 View 控件;若未找到则返回 TextBlock 显示错误信息。</returns>
public Control? Build(object? param)
{
if (param is null)
return null;
var name = param.GetType().FullName!.Replace("ViewModel", "View", StringComparison.Ordinal);
var type = Type.GetType(name);
if (type != null)
{
return (Control)Activator.CreateInstance(type)!;
}
return new TextBlock { Text = "Not Found: " + name };
}
/// <summary>
/// 判断数据对象是否为 ViewModel 类型(以 "ViewModel" 结尾)。
/// </summary>
/// <param name="data">要判断的数据对象。</param>
/// <returns>是否为 ViewModel。</returns>
public bool Match(object? data)
{
return data is ViewModelBase;
}
}
}

View File

@ -0,0 +1,13 @@
namespace Avalonia_PC.ViewModels
{
/// <summary>
/// 主窗口的 ViewModel提供问候语等绑定属性。
/// </summary>
public partial class MainWindowViewModel : ViewModelBase
{
/// <summary>
/// 获取问候语文本。
/// </summary>
public string Greeting { get; } = "Welcome to Avalonia!";
}
}

View File

@ -0,0 +1,12 @@
using CommunityToolkit.Mvvm.ComponentModel;
namespace Avalonia_PC.ViewModels
{
/// <summary>
/// ViewModel 基类,继承自 CommunityToolkit.Mvvm 的 ObservableObject
/// 提供属性变更通知功能。
/// </summary>
public abstract class ViewModelBase : ObservableObject
{
}
}

View File

@ -0,0 +1,345 @@
namespace Avalonia_PC.Views
{
/// <summary>
/// MainWindow 的分部类,定义注入 WebView2 的 JavaScript Bridge 脚本。
/// </summary>
public partial class MainWindow
{
private const string BridgeScript = """
if (!window.__appBridgeInstalled) {
window.__appBridgeInstalled = true;
window.isWebView2 = true;
const pending = new Map();
const tryOpenDevTools = () => {
window.invokeCSharpAction(JSON.stringify({ kind: 'app-open-devtools' }));
};
window.__dispatchAppResponse = function(jsonStr) {
const payload = JSON.parse(jsonStr);
const responseId = payload.id ?? payload.Id;
const entry = pending.get(responseId);
if (!entry) return;
pending.delete(responseId);
entry.resolve(new Response(payload.body ?? payload.Body ?? '', {
status: payload.statusCode ?? payload.StatusCode ?? 200,
statusText: payload.statusMessage ?? payload.StatusMessage ?? 'OK',
headers: payload.headers ?? payload.Headers ?? { 'Content-Type': 'application/json' }
}));
};
const nativeFetch = window.fetch ? window.fetch.bind(window) : null;
const NativeXMLHttpRequest = window.XMLHttpRequest;
const sendAppBridgeRequest = ({ requestUrl, method, headers, body, timeoutMs = 30000 }) => {
const id = globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random()}`;
const responsePromise = new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
pending.delete(id);
reject(new Error(`Timed out waiting for ${requestUrl}`));
}, timeoutMs);
pending.set(id, {
resolve: response => { clearTimeout(timeoutId); resolve(response); },
reject: error => { clearTimeout(timeoutId); reject(error); }
});
});
window.invokeCSharpAction(JSON.stringify({
kind: 'app-request',
id,
url: requestUrl,
method,
headers,
body
}));
return responsePromise;
};
document.addEventListener('keydown', event => {
if (event.key === 'F12' || (event.ctrlKey && event.shiftKey && (event.key === 'I' || event.key === 'i'))) {
event.preventDefault();
tryOpenDevTools();
}
}, true);
document.addEventListener('contextmenu', event => {
if (event.shiftKey) {
event.preventDefault();
tryOpenDevTools();
}
}, true);
window.fetch = async (input, init) => {
const request = input instanceof Request ? input : null;
const requestUrl = typeof input === 'string' || input instanceof URL
? input.toString()
: request?.url;
if (!requestUrl || !requestUrl.startsWith('app://')) {
if (!nativeFetch) throw new Error('window.fetch is not available.');
return nativeFetch(input, init);
}
const combinedHeaders = new Headers(request?.headers);
if (init?.headers) {
new Headers(init.headers).forEach((value, key) => combinedHeaders.set(key, value));
}
const headers = {};
combinedHeaders.forEach((value, key) => headers[key] = value);
let body = init?.body;
if (body === undefined && request) {
body = await request.clone().text();
}
if (body && typeof body !== 'string') {
body = await new Response(body).text();
}
return sendAppBridgeRequest({
requestUrl,
method: init?.method ?? request?.method ?? 'GET',
headers,
body: body ?? null,
timeoutMs: 30000
});
};
/// <summary>
/// WebView2 Bridge 中的 XMLHttpRequest 替代实现,将 app:// 请求拦截并转为 C# 调用。
/// </summary>
class BridgeXMLHttpRequest {
constructor() {
this._native = new NativeXMLHttpRequest();
this._isAppRequest = false;
this._requestUrl = '';
this._method = 'GET';
this._headers = {};
this._responseHeaders = {};
this._responseHeadersRaw = '';
this._aborted = false;
this.readyState = 0;
this.status = 0;
this.statusText = '';
this.response = null;
this.responseText = '';
this.responseType = '';
this.responseURL = '';
this.timeout = 0;
this.withCredentials = false;
this.onreadystatechange = null;
this.onload = null;
this.onerror = null;
this.ontimeout = null;
this.onabort = null;
this.onloadend = null;
this.upload = {
addEventListener: () => {},
removeEventListener: () => {}
};
this._native.onreadystatechange = () => {
if (this._isAppRequest) {
return;
}
this.readyState = this._native.readyState;
this.status = this._native.status;
this.statusText = this._native.statusText;
this.responseURL = this._native.responseURL ?? '';
this.response = this._native.response;
this.responseText = this._native.responseText ?? '';
this._raiseReadyStateChange();
};
this._native.onload = event => {
if (!this._isAppRequest && typeof this.onload === 'function') {
this.onload(event);
}
};
this._native.onerror = event => {
if (!this._isAppRequest && typeof this.onerror === 'function') {
this.onerror(event);
}
};
this._native.ontimeout = event => {
if (!this._isAppRequest && typeof this.ontimeout === 'function') {
this.ontimeout(event);
}
};
this._native.onabort = event => {
if (!this._isAppRequest && typeof this.onabort === 'function') {
this.onabort(event);
}
};
this._native.onloadend = event => {
if (!this._isAppRequest && typeof this.onloadend === 'function') {
this.onloadend(event);
}
};
}
open(method, url, async = true, user, password) {
const requestUrl = typeof url === 'string' || url instanceof URL
? url.toString()
: `${url ?? ''}`;
this._requestUrl = requestUrl;
this._method = method ?? 'GET';
this._isAppRequest = requestUrl.startsWith('app://');
this._headers = {};
this._responseHeaders = {};
this._responseHeadersRaw = '';
this._aborted = false;
if (!this._isAppRequest) {
this._native.open(method, url, async, user, password);
return;
}
this.readyState = 1;
this._raiseReadyStateChange();
}
setRequestHeader(name, value) {
if (!this._isAppRequest) {
this._native.setRequestHeader(name, value);
return;
}
this._headers[name] = value;
}
getAllResponseHeaders() {
if (!this._isAppRequest) {
return this._native.getAllResponseHeaders();
}
return this._responseHeadersRaw;
}
getResponseHeader(name) {
if (!this._isAppRequest) {
return this._native.getResponseHeader(name);
}
return this._responseHeaders[name.toLowerCase()] ?? null;
}
overrideMimeType(mimeType) {
if (!this._isAppRequest && typeof this._native.overrideMimeType === 'function') {
this._native.overrideMimeType(mimeType);
}
}
abort() {
if (!this._isAppRequest) {
this._native.abort();
return;
}
this._aborted = true;
if (typeof this.onabort === 'function') {
this.onabort();
}
if (typeof this.onloadend === 'function') {
this.onloadend();
}
}
async send(body = null) {
if (!this._isAppRequest) {
this._native.send(body);
return;
}
let requestBody = body;
if (requestBody && typeof requestBody !== 'string') {
requestBody = await new Response(requestBody).text();
}
try {
const response = await sendAppBridgeRequest({
requestUrl: this._requestUrl,
method: this._method,
headers: this._headers,
body: requestBody ?? null,
timeoutMs: this.timeout > 0 ? this.timeout : 30000
});
if (this._aborted) {
return;
}
this.status = response.status;
this.statusText = response.statusText;
this.responseURL = this._requestUrl;
this._responseHeaders = {};
this._responseHeadersRaw = '';
response.headers.forEach((value, key) => {
this._responseHeaders[key.toLowerCase()] = value;
this._responseHeadersRaw += `${key}: ${value}\r\n`;
});
const text = await response.text();
this.responseText = text;
this.response = this.responseType === 'json'
? (text ? JSON.parse(text) : null)
: text;
this.readyState = 4;
this._raiseReadyStateChange();
if (typeof this.onload === 'function') {
this.onload();
}
if (typeof this.onloadend === 'function') {
this.onloadend();
}
} catch (error) {
if (this._aborted) {
return;
}
this.status = 0;
this.statusText = '';
this.readyState = 4;
this._raiseReadyStateChange();
const errorMessage = error?.message ?? '';
if (errorMessage.includes('Timed out waiting') && typeof this.ontimeout === 'function') {
this.ontimeout(error);
} else if (typeof this.onerror === 'function') {
this.onerror(error);
}
if (typeof this.onloadend === 'function') {
this.onloadend();
}
}
}
_raiseReadyStateChange() {
if (typeof this.onreadystatechange === 'function') {
this.onreadystatechange();
}
}
}
window.XMLHttpRequest = BridgeXMLHttpRequest;
}
""";
}
}

View File

@ -0,0 +1,38 @@
using Avalonia_Services.Core;
using Avalonia_Services.Endpoints;
using Avalonia_Services.Extensions;
using Avalonia_Services.Services;
using Microsoft.Extensions.DependencyInjection;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace Avalonia_PC.Views
{
/// <summary>
/// MainWindow 的分部类,负责路由注册和统一端点适配。
/// </summary>
public partial class MainWindow
{
/// <summary>
/// 统一端点适配器(替代原来的 _routes 字典)。
/// 所有端点在 Avalonia-Services/AppEndpoints.cs 中统一定义。
/// </summary>
private DesktopEndpointAdapter _endpointAdapter = null!;
/// <summary>
/// 服务容器,通过构造函数注入。
/// </summary>
private IServiceProvider _services = null!;
/// <summary>
/// 从 DI 获取统一端点集合并构建桌面适配器。
/// </summary>
private void RegisterRoutes()
{
// 从 DI 获取已构建的端点集合
var endpointCollection = _services.GetRequiredService<ServiceEndpointCollection>();
_endpointAdapter = endpointCollection.CreateAdapter(_services);
}
}
}

View File

@ -0,0 +1,23 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="using:Avalonia_PC.ViewModels"
xmlns:webview="clr-namespace:Avalonia.Controls;assembly=Avalonia.Controls.WebView"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="Avalonia_PC.Views.MainWindow"
x:DataType="vm:MainWindowViewModel"
Icon="/Assets/avalonia-logo.ico"
Title="Avalonia_PC">
<Design.DataContext>
<!-- This only sets the DataContext for the previewer in an IDE,
to set the actual DataContext for runtime, set the DataContext property in code (look at App.axaml.cs) -->
<vm:MainWindowViewModel/>
</Design.DataContext>
<Grid>
<webview:NativeWebView x:Name="WebView" />
</Grid>
</Window>

View File

@ -0,0 +1,684 @@
using Avalonia.Controls;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Sockets;
using System.Reflection;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace Avalonia_PC.Views
{
/// <summary>
/// 主窗口,承载 WebView2 控件并管理前后端 Bridge 通信。
/// </summary>
public partial class MainWindow : Window
{
/// <summary>
/// 自定义协议方案名称。
/// </summary>
private const string AppScheme = "app";
/// <summary>
/// 在线模式下的前端启动 URL。
/// </summary>
private const string? OnlineStartupUrl = "http://localhost:51240";
//private const string? OnlineStartupUrl = null;
/// <summary>
/// 离线模式下的前端本地文件路径,为空则使用在线模式。
/// </summary>
private const string? LocalStartupPath = null;
private static readonly JsonSerializerOptions BridgeJsonSerializerOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};
/// <summary>
/// WebView2 原生控件实例。
/// </summary>
private NativeWebView? _webView;
/// <summary>
/// 标记 WebView 事件是否已绑定。
/// </summary>
private bool _eventsAttached;
/// <summary>
/// WebView 适配器对象。
/// </summary>
private object? _webViewAdapter;
/// <summary>
/// 本地 HTTP 服务器实例(离线模式)。
/// </summary>
private HttpListener? _localHttpServer;
/// <summary>
/// 本地 HTTP 服务器的取消令牌源。
/// </summary>
private CancellationTokenSource? _localHttpServerCts;
/// <summary>
/// 本地 HTTP 服务器的基础 URL。
/// </summary>
private string? _localHttpBaseUrl;
/// <summary>
/// 本地 HTTP 服务器的根目录路径。
/// </summary>
private string? _localHttpRoot;
#region WebView
/// <summary>
/// 初始化窗口并注册生命周期事件。
/// </summary>
public MainWindow(IServiceProvider services)
{
_services = services;
InitializeComponent();
Opened += OnOpened;
Closed += OnClosed;
RegisterRoutes();
}
/// <summary>
/// 窗口打开后初始化 WebView、挂载事件并加载入口页面。
/// </summary>
private async void OnOpened(object? sender, EventArgs e)
{
if (_eventsAttached)
{
return;
}
_webView = this.FindControl<NativeWebView>("WebView");
if (_webView is null)
{
return;
}
_eventsAttached = true;
_webView.NavigationCompleted += OnNavigationCompleted;
_webView.WebMessageReceived += OnWebMessageReceived;
_webView.AdapterCreated += OnAdapterCreated;
await LoadInitialContentAsync();
}
/// <summary>
/// WebView 适配器创建后缓存实例,用于后续打开开发者工具。
/// </summary>
private void OnAdapterCreated(object? sender, WebViewAdapterEventArgs e)
{
_webViewAdapter = e.GetType().GetProperty("Adapter")?.GetValue(e);
}
/// <summary>
/// 窗口关闭时解绑事件并释放本地资源。
/// </summary>
private void OnClosed(object? sender, EventArgs e)
{
if (_webView is not null)
{
_webView.NavigationCompleted -= OnNavigationCompleted;
_webView.WebMessageReceived -= OnWebMessageReceived;
_webView.AdapterCreated -= OnAdapterCreated;
}
_webViewAdapter = null;
StopLocalHttpServer();
}
/// <summary>
/// 页面导航完成后注入 JS 桥接脚本。
/// </summary>
private async void OnNavigationCompleted(object? sender, WebViewNavigationCompletedEventArgs e)
{
await InjectBridgeScriptAsync();
}
#endregion
#region
/// <summary>
/// 接收前端消息并进行分发(打开调试工具 / 处理 app 请求)。
/// </summary>
private async void OnWebMessageReceived(object? sender, WebMessageReceivedEventArgs e)
{
var messageJson = e.Body;
if (string.IsNullOrWhiteSpace(messageJson))
{
return;
}
AppResponse? response = null;
try
{
using var document = JsonDocument.Parse(messageJson);
var root = document.RootElement;
if (!root.TryGetProperty("kind", out var kindProperty))
{
return;
}
var kind = kindProperty.GetString();
if (string.Equals(kind, "app-open-devtools", StringComparison.OrdinalIgnoreCase))
{
TryOpenDevTools();
return;
}
if (!string.Equals(kind, "app-request", StringComparison.OrdinalIgnoreCase))
{
return;
}
response = await HandleAppRequestAsync(root);
}
catch (Exception ex)
{
response = new AppResponse
{
Kind = "app-response",
Id = TryGetRequestId(messageJson),
StatusCode = 500,
StatusMessage = "Internal Server Error",
Body = JsonSerializer.Serialize(new { success = false, error = ex.Message }),
Headers = CreateJsonHeaders(),
};
}
if (_webView is not null && response is not null)
{
var responseJson = JsonSerializer.Serialize(response, BridgeJsonSerializerOptions);
var responseJsonLiteral = JsonSerializer.Serialize(responseJson);
await _webView.InvokeScript($"window.__dispatchAppResponse({responseJsonLiteral})");
}
}
/// <summary>
/// 加载初始页面:优先在线地址,其次本地路径(通过本地 HTTP 服务托管)。
/// </summary>
private async Task LoadInitialContentAsync()
{
if (_webView is null)
{
return;
}
var onlineUrl = GetConfiguredOnlineStartupUrl();
if (onlineUrl is not null)
{
StopLocalHttpServer();
_webView.Source = onlineUrl;
return;
}
var localHtmlPath = GetConfiguredLocalStartupPath();
if (string.IsNullOrWhiteSpace(localHtmlPath) || !File.Exists(localHtmlPath))
{
return;
}
var localRoot = Path.GetDirectoryName(localHtmlPath);
if (string.IsNullOrWhiteSpace(localRoot))
{
return;
}
await EnsureLocalHttpServerStartedAsync(localRoot);
if (string.IsNullOrWhiteSpace(_localHttpBaseUrl))
{
_webView.Source = new Uri(localHtmlPath);
return;
}
_webView.Source = new Uri(new Uri(_localHttpBaseUrl), Path.GetFileName(localHtmlPath));
}
/// <summary>
/// 向页面注入桥接脚本,接管 app:// 请求并回传到 C# 处理。
/// </summary>
private async Task InjectBridgeScriptAsync()
{
if (_webView is null)
{
return;
}
await _webView.InvokeScript(BridgeScript);
}
#endregion
#region
/// <summary>
/// 解析前端请求消息并转发到统一请求处理入口。
/// </summary>
private async Task<AppResponse> HandleAppRequestAsync(JsonElement request)
{
var id = request.TryGetProperty("id", out var idProperty) ? idProperty.GetString() : null;
var url = request.TryGetProperty("url", out var urlProperty) ? urlProperty.GetString() : null;
var method = request.TryGetProperty("method", out var methodProperty) ? methodProperty.GetString() : "GET";
var body = request.TryGetProperty("body", out var bodyProperty) ? bodyProperty.GetString() : null;
var headers = ExtractHeaders(request);
return await HandleAppRequestAsync(id, url, method, body, headers);
}
/// <summary>
/// 统一请求处理:构建上下文、处理 OPTIONS、使用统一端点适配器分发。
/// </summary>
private async Task<AppResponse> HandleAppRequestAsync(
string? id,
string? rawUrl,
string? method,
string? body,
Dictionary<string, string> headers)
{
var response = new AppResponse
{
Kind = "app-response",
Id = id,
StatusCode = 200,
StatusMessage = "OK",
Headers = CreateJsonHeaders(),
};
try
{
var uri = new Uri(rawUrl ?? throw new InvalidOperationException("请求地址不能为空。"));
if (string.Equals(method, "OPTIONS", StringComparison.OrdinalIgnoreCase))
{
response.StatusCode = 200;
response.StatusMessage = "OK";
response.Body = JsonSerializer.Serialize(new { success = true });
return response;
}
// 使用统一端点适配器处理请求
var (normalizedPath, queryParams) = ParseRequestUri(uri);
var routeResult = await _endpointAdapter.HandleRequestAsync(
path: normalizedPath,
method: method ?? "GET",
body: body,
headers: headers,
query: queryParams);
if (routeResult.IsMatched)
{
response.StatusCode = routeResult.StatusCode;
response.StatusMessage = routeResult.StatusMessage;
response.Body = BuildSuccessResponseBody(routeResult.Data);
foreach (var kvp in routeResult.ResponseHeaders)
{
response.Headers[kvp.Key] = kvp.Value;
}
return response;
}
response.StatusCode = 404;
response.StatusMessage = "Not Found";
response.Body = JsonSerializer.Serialize(new { success = false, error = "API not found" });
return response;
}
catch (Exception ex)
{
response.StatusCode = 500;
response.StatusMessage = "Internal Server Error";
response.Body = JsonSerializer.Serialize(new { success = false, error = ex.Message });
return response;
}
}
/// <summary>
/// 从 URI 解析规范化路径和查询参数(供统一端点适配器使用)。
/// </summary>
private static (string normalizedPath, Dictionary<string, string> query) ParseRequestUri(Uri uri)
{
var host = uri.Host ?? string.Empty;
var absolutePath = uri.AbsolutePath ?? string.Empty;
var combinedPath = $"{host}/{absolutePath}";
var pathSegments = combinedPath
.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(Uri.UnescapeDataString)
.ToArray();
var normalizedPath = string.Join('/', pathSegments);
var query = ParseQueryParameters(uri.Query);
return (normalizedPath, query);
}
/// <summary>
/// 统一构建成功响应体,保持前后端响应结构一致。
/// </summary>
private static string BuildSuccessResponseBody(object? data)
{
return JsonSerializer.Serialize(new { success = true, data });
}
/// <summary>
/// 解析查询字符串为忽略大小写的字典。
/// </summary>
private static Dictionary<string, string> ParseQueryParameters(string? queryString)
{
var query = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (string.IsNullOrWhiteSpace(queryString))
{
return query;
}
var raw = queryString.TrimStart('?');
foreach (var pair in raw.Split('&', StringSplitOptions.RemoveEmptyEntries))
{
var separatorIndex = pair.IndexOf('=');
if (separatorIndex < 0)
{
query[Uri.UnescapeDataString(pair)] = string.Empty;
continue;
}
var key = Uri.UnescapeDataString(pair[..separatorIndex]);
var value = Uri.UnescapeDataString(pair[(separatorIndex + 1)..]);
query[key] = value;
}
return query;
}
/// <summary>
/// 创建桥接响应的默认 JSON/CORS 头。
/// </summary>
private static Dictionary<string, string> CreateJsonHeaders() => new()
{
["Content-Type"] = "application/json; charset=utf-8",
["Access-Control-Allow-Origin"] = "*",
["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS",
["Access-Control-Allow-Headers"] = "Content-Type, Authorization",
};
/// <summary>
/// 从前端请求消息中提取请求头。
/// </summary>
private static Dictionary<string, string> ExtractHeaders(JsonElement request)
{
if (!request.TryGetProperty("headers", out var headersElement) ||
headersElement.ValueKind != JsonValueKind.Object)
{
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
}
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var property in headersElement.EnumerateObject())
{
headers[property.Name] = property.Value.GetString() ?? string.Empty;
}
return headers;
}
/// <summary>
/// 获取授权头,供鉴权逻辑扩展使用。
/// </summary>
private static string? GetAuthorizationHeader(Dictionary<string, string> headers)
{
return headers.FirstOrDefault(
entry => string.Equals(entry.Key, "Authorization", StringComparison.OrdinalIgnoreCase)).Value;
}
/// <summary>
/// 在异常情况下尝试提取请求 id确保前端可收到对应错误响应。
/// </summary>
private static string? TryGetRequestId(string messageJson)
{
try
{
using var document = JsonDocument.Parse(messageJson);
return document.RootElement.TryGetProperty("id", out var idProperty) ? idProperty.GetString() : null;
}
catch
{
return null;
}
}
#endregion
#region
/// <summary>
/// 获取在线启动地址配置(仅允许 http/https
/// </summary>
private static Uri? GetConfiguredOnlineStartupUrl()
{
if (string.IsNullOrWhiteSpace(OnlineStartupUrl))
{
return null;
}
if (!Uri.TryCreate(OnlineStartupUrl, UriKind.Absolute, out var uri))
{
return null;
}
return uri.Scheme is "http" or "https" ? uri : null;
}
/// <summary>
/// 获取本地启动文件路径,未配置时默认使用输出目录 www/index.html。
/// </summary>
private static string? GetConfiguredLocalStartupPath()
{
if (!string.IsNullOrWhiteSpace(LocalStartupPath))
{
return Path.GetFullPath(LocalStartupPath);
}
return Path.Combine(AppContext.BaseDirectory, "www", "index.html");
}
/// <summary>
/// 确保本地 HTTP 静态服务已启动;根目录变化时会重启。
/// </summary>
private async Task EnsureLocalHttpServerStartedAsync(string localRoot)
{
if (!string.IsNullOrWhiteSpace(_localHttpBaseUrl) &&
_localHttpServer is not null &&
string.Equals(_localHttpRoot, localRoot, StringComparison.OrdinalIgnoreCase))
{
return;
}
StopLocalHttpServer();
var port = GetAvailableTcpPort();
var prefix = $"http://127.0.0.1:{port}/";
_localHttpServerCts = new CancellationTokenSource();
_localHttpServer = new HttpListener();
_localHttpServer.Prefixes.Add(prefix);
_localHttpServer.Start();
_localHttpBaseUrl = prefix;
_localHttpRoot = localRoot;
_ = Task.Run(() => RunLocalHttpServerLoopAsync(_localHttpServer, _localHttpServerCts.Token, localRoot));
}
/// <summary>
/// 本地静态服务主循环,持续接收并分发请求。
/// </summary>
private static async Task RunLocalHttpServerLoopAsync(HttpListener listener, CancellationToken cancellationToken, string wwwRoot)
{
try
{
while (!cancellationToken.IsCancellationRequested)
{
var context = await listener.GetContextAsync();
_ = Task.Run(() => HandleLocalHttpRequest(context, wwwRoot), cancellationToken);
}
}
catch
{
}
}
/// <summary>
/// 处理本地静态资源请求并返回文件内容。
/// </summary>
private static async Task HandleLocalHttpRequest(HttpListenerContext context, string wwwRoot)
{
try
{
var relativePath = context.Request.Url?.AbsolutePath.TrimStart('/') ?? string.Empty;
if (string.IsNullOrWhiteSpace(relativePath))
{
relativePath = "index.html";
}
relativePath = relativePath.Replace('/', Path.DirectorySeparatorChar);
var fullPath = Path.GetFullPath(Path.Combine(wwwRoot, relativePath));
var fullRoot = Path.GetFullPath(wwwRoot);
if (!fullPath.StartsWith(fullRoot, StringComparison.OrdinalIgnoreCase) || !File.Exists(fullPath))
{
context.Response.StatusCode = 404;
context.Response.Close();
return;
}
context.Response.ContentType = GetContentType(fullPath);
await using var input = File.OpenRead(fullPath);
context.Response.ContentLength64 = input.Length;
await input.CopyToAsync(context.Response.OutputStream);
context.Response.OutputStream.Close();
}
catch
{
try
{
context.Response.StatusCode = 500;
context.Response.Close();
}
catch
{
}
}
}
/// <summary>
/// 根据后缀返回静态资源 Content-Type。
/// </summary>
private static string GetContentType(string filePath)
{
return Path.GetExtension(filePath).ToLowerInvariant() switch
{
".html" => "text/html; charset=utf-8",
".js" => "application/javascript; charset=utf-8",
".css" => "text/css; charset=utf-8",
".json" => "application/json; charset=utf-8",
_ => "application/octet-stream",
};
}
/// <summary>
/// 获取一个可用本地端口,用于启动本地静态服务。
/// </summary>
private static int GetAvailableTcpPort()
{
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
listener.Stop();
return port;
}
/// <summary>
/// 尝试打开 WebView 开发者工具(兼容不同适配器方法名)。
/// </summary>
private void TryOpenDevTools()
{
if (_webViewAdapter is null)
{
return;
}
var adapterType = _webViewAdapter.GetType();
var method = adapterType.GetMethod("OpenDevTools", BindingFlags.Public | BindingFlags.Instance) ??
adapterType.GetMethod("ShowDevTools", BindingFlags.Public | BindingFlags.Instance);
method?.Invoke(_webViewAdapter, null);
}
/// <summary>
/// 停止并释放本地静态服务资源。
/// </summary>
private void StopLocalHttpServer()
{
try
{
_localHttpServerCts?.Cancel();
_localHttpServer?.Stop();
_localHttpServer?.Close();
}
catch
{
}
finally
{
_localHttpServerCts?.Dispose();
_localHttpServerCts = null;
_localHttpServer = null;
_localHttpBaseUrl = null;
_localHttpRoot = null;
}
}
#endregion
#region DTO /
/// <summary>
/// Bridge 通信响应 DTO用于序列化返回给前端的数据。
/// </summary>
private sealed class AppResponse
{
/// <summary>
/// 获取或设置响应类型标识。
/// </summary>
public string Kind { get; set; } = string.Empty;
/// <summary>
/// 获取或设置请求 ID对应前端请求
/// </summary>
public string? Id { get; set; }
/// <summary>
/// 获取或设置 HTTP 状态码。
/// </summary>
public int StatusCode { get; set; }
/// <summary>
/// 获取或设置状态描述文本。
/// </summary>
public string StatusMessage { get; set; } = string.Empty;
/// <summary>
/// 获取或设置响应体 JSON 字符串。
/// </summary>
public string Body { get; set; } = string.Empty;
/// <summary>
/// 获取或设置响应头字典。
/// </summary>
public Dictionary<string, string> Headers { get; set; } = new();
}
#endregion
}
}

18
Avalonia-PC/app.manifest Normal file
View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<!-- This manifest is used on Windows only.
Don't remove it as it might cause problems with window transparency and embedded controls.
For more details visit https://learn.microsoft.com/en-us/windows/win32/sbscs/application-manifests -->
<assemblyIdentity version="1.0.0.0" name="Avalonia_PC.Desktop"/>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- A list of the Windows versions that this application has been tested on
and is designed to work with. Uncomment the appropriate elements
and Windows will automatically select the most compatible environment. -->
<!-- Windows 10 -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
</assembly>

51
Avalonia-PC/www/api.js Normal file
View File

@ -0,0 +1,51 @@
// api.js - 跨端统一 API 调用层
const isWebView2 = () => {
return window.isWebView2 === true;
};
const getBaseUrl = () => {
if (isWebView2()) {
return "app://api/";
}
return "https://your-production-api.com/api/";
};
async function callApi(endpoint, options = {}) {
const url = getBaseUrl() + endpoint;
const fetchOptions = {
method: options.method || "GET",
headers: {
"Content-Type": "application/json",
...(options.headers || {})
},
...(options.body && { body: JSON.stringify(options.body) })
};
const token = localStorage.getItem("authToken");
if (token) {
fetchOptions.headers.Authorization = `Bearer ${token}`;
}
try {
const response = await fetch(url, fetchOptions);
const data = await response.json();
console.log(data)
if (!response.ok) {
throw new Error(data.error || `HTTP ${response.status}`);
}
return data;
} catch (err) {
console.error(`API call failed: ${endpoint}`, err);
throw err;
}
}
window.api = {
getUser: () => callApi("getUser?t=1"),
processData: (input) => callApi("processData", { method: "POST", body: { input } }),
wData: (input) => callApi("wData", { method: "POST", body: { input } }),
};

View File

@ -0,0 +1,62 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>跨端测试</title>
</head>
<body>
<h1>WebView2 自定义协议演示</h1>
<button id="getUserBtn">获取用户信息</button>
<button id="processBtn">处理数据</button>
<button id="wBtn">天气数据</button>
<pre id="output"></pre>
<script src="./api.js"></script>
<script>
const output = document.getElementById('output');
document.getElementById('getUserBtn').onclick = async () => {
try {
const result = await window.api.getUser();
output.textContent = JSON.stringify(result, null, 2);
} catch (err) {
output.textContent = `错误: ${err.message}`;
}
};
document.getElementById('processBtn').onclick = async () => {
try {
const result = await window.api.processData('hello world');
output.textContent = JSON.stringify(result, null, 2);
} catch (err) {
output.textContent = `错误: ${err.message}`;
}
};
document.getElementById('wBtn').onclick = async () => {
try {
const result = await window.api.wData('hello world');
output.textContent = JSON.stringify(result, null, 2);
} catch (err) {
output.textContent = `错误: ${err.message}`;
}
};
const detectIsWebView2 = () => window.isWebView2 === true || typeof window.invokeCSharpAction === 'function';
const renderEnvironment = () => {
const isWV2 = detectIsWebView2();
const existing = document.getElementById('envTip');
if (existing) {
existing.textContent = `当前环境: ${isWV2 ? 'WebView2 (自定义协议)' : '普通浏览器 (HTTP API)'}`;
return;
}
document.body.insertAdjacentHTML('beforeend', `<p id="envTip">当前环境: ${isWV2 ? 'WebView2 (自定义协议)' : '普通浏览器 (HTTP API)'}</p>`);
};
renderEnvironment();
setTimeout(renderEnvironment, 300);
</script>
</body>
</html>

View File

@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<RootNamespace>Avalonia_Services</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.7" />
<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="..\Avalonia-Common\Avalonia-Common.csproj" />
<ProjectReference Include="..\Avalonia-EFCore\Avalonia-EFCore.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,70 @@
using System;
using System.Linq;
namespace Avalonia_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();
}
}
}

View File

@ -0,0 +1,107 @@
using Avalonia_Common.Core;
using System;
using System.Threading.Tasks;
namespace Avalonia_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}");
}
}
}
}

View File

@ -0,0 +1,39 @@
using System.Security.Claims;
namespace Avalonia_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);
}
}
}

View File

@ -0,0 +1,48 @@
using System.Threading.Tasks;
namespace Avalonia_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);
}
}
}

View File

@ -0,0 +1,364 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
namespace Avalonia_Services.Core
{
/// <summary>
/// 端点挂载的宿主目标。
/// </summary>
[Flags]
public enum EndpointHostTarget
{
/// <summary>挂载到 Avalonia-APIASP.NET Core Web API。</summary>
Api = 1,
/// <summary>挂载到 Avalonia-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>
/// 只挂载到 Avalonia-API。
/// </summary>
public ServiceEndpoint ApiOnly()
{
HostTarget = EndpointHostTarget.Api;
return this;
}
/// <summary>
/// 只挂载到 Avalonia-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>
/// 端点集合 —— 所有端点的注册中心。在 Avalonia-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>
/// <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>
/// 注册一个 POST 端点。
/// </summary>
public ServiceEndpoint MapPost(string pattern, Func<ServiceEndpointContext, Task<object?>> handler)
{
return AddEndpoint(pattern, "POST", 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>
/// 注册一个 PUT 端点。
/// </summary>
public ServiceEndpoint MapPut(string pattern, Func<ServiceEndpointContext, Task<object?>> handler)
{
return AddEndpoint(pattern, "PUT", 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>
/// 注册一个 DELETE 端点。
/// </summary>
public ServiceEndpoint MapDelete(string pattern, Func<ServiceEndpointContext, Task<object?>> handler)
{
return AddEndpoint(pattern, "DELETE", 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>
/// 添加全局过滤器(作用于所有端点)。
/// </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>
/// 构建器 —— 提供 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;
}
}
}

View File

@ -0,0 +1,79 @@
using System.Collections.Generic;
namespace Avalonia_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 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;
}
}
}

View File

@ -0,0 +1,147 @@
using Avalonia_Common.Core;
using Avalonia_EFCore.Database;
using Avalonia_EFCore.Models;
using Avalonia_Services.Core;
using Avalonia_Services.Services;
using Microsoft.EntityFrameworkCore;
namespace Avalonia_Services.Endpoints
{
/// <summary>
/// 统一端点配置 —— 所有业务端点在此定义一次。
/// 这是 Avalonia-API 和 Avalonia-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("api/wData", GetWeatherForecastsAsync)
.WithOpenApi("Weather", "获取天气预报信息。")
.WithName("GetWeatherForecast");
// 获取用户(演示从数据库查询)
endpoints.MapGet("api/getUser", GetUserFromDatabaseAsync)
.WithName("GetUser");
// 处理数据POST — 演示参数处理)
endpoints.MapPost("api/processData", ProcessDataAsync)
.WithName("ProcessData");
// ---- 需要鉴权的端点示例 ----
// endpoints.MapGet("api/admin/dashboard", AdminDashboardAsync)
// .WithName("AdminDashboard")
// .RequireAuthorization = true
// .Policy = "AdminOnly";
});
return builder;
}
#region
/// <summary>
/// 从数据库查询天气预报(优先数据库,回退到内存生成)。
/// </summary>
private static async Task<object?> GetWeatherForecastsAsync(ServiceEndpointContext ctx)
{
var sp = ctx.Items["ServiceProvider"] as IServiceProvider;
// 尝试从数据库读取
if (sp?.GetService(typeof(AppDataContext)) is AppDataContext db)
{
var dbForecasts = await db.WeatherForecasts
.OrderByDescending(f => f.Date)
.Take(5)
.ToListAsync();
if (dbForecasts.Count > 0)
{
return ResponseHelper.Ok(dbForecasts, "获取天气预报成功(来自数据库)");
}
}
// 回退:内存生成(数据库为空时)
var service = sp?.GetService(typeof(WeatherForecastService)) as WeatherForecastService
?? new WeatherForecastService();
var forecasts = service.GetWeatherForecasts();
return ResponseHelper.Ok(forecasts, "获取天气预报成功(内存生成)");
}
/// <summary>
/// 从数据库获取用户信息(演示数据库查询),若无数据则返回演示用户。
/// </summary>
/// <param name="ctx">服务端点上下文。</param>
/// <returns>用户信息。</returns>
private static async Task<object?> GetUserFromDatabaseAsync(ServiceEndpointContext ctx)
{
var sp = ctx.Items["ServiceProvider"] as IServiceProvider;
// 尝试从数据库读取用户
if (sp?.GetService(typeof(AppDataContext)) is AppDataContext db)
{
var users = await db.Set<UserEntity>().Take(1).ToListAsync();
if (users.Count > 0)
{
return ResponseHelper.Ok(users[0], "获取用户成功(来自数据库)");
}
}
// 回退:演示数据
await Task.Delay(100);
var user = new { id = 1, name = "张三", email = "zhangsan@example.com" };
return ResponseHelper.Ok(user);
}
/// <summary>
/// 处理前端发送的数据POST 演示),将数据存入数据库或转为大写返回。
/// </summary>
/// <param name="ctx">服务端点上下文。</param>
/// <returns>处理结果。</returns>
private static async Task<object?> ProcessDataAsync(ServiceEndpointContext ctx)
{
var sp = ctx.Items["ServiceProvider"] as IServiceProvider;
// 演示:将收到的数据存入数据库
var input = ctx.Body ?? string.Empty;
if (sp?.GetService(typeof(AppDataContext)) is AppDataContext db && !string.IsNullOrWhiteSpace(input))
{
var forecast = new WeatherForecastEntity
{
Date = DateOnly.FromDateTime(DateTime.Now),
TemperatureC = 20,
Summary = input,
};
db.WeatherForecasts.Add(forecast);
await db.SaveChangesAsync();
return ResponseHelper.Ok(forecast, "数据已存入数据库");
}
await Task.Delay(200);
return ResponseHelper.Ok(new { input, processed = input.ToUpperInvariant() });
}
#endregion
}
}

View File

@ -0,0 +1,61 @@
using Avalonia_Services.Core;
using Avalonia_Services.Services.AuthService;
namespace Avalonia_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>("api/auth/login", (service, ctx) => service.LoginAsync(ctx))
.WithName("ApiLogin")
.WithOpenApi("Auth", "API 登录,返回 access token 和 refresh token。", "", typeof(ApiLoginRequest), typeof(AuthTokenResponse))
.ApiOnly();
endpoints.MapPost<IApiAuthEndpointService>("api/auth/refresh", (service, ctx) => service.RefreshAsync(ctx))
.WithName("ApiRefresh")
.WithOpenApi("Auth", "API refresh token 轮换。", "", typeof(ApiRefreshTokenRequest), typeof(AuthTokenResponse))
.ApiOnly();
endpoints.MapPost<IApiAuthEndpointService>("api/auth/logout", (service, ctx) => service.LogoutAsync(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>("api/pc/auth/authorize", (service, ctx) => service.AuthorizeAsync(ctx))
.WithName("PcAuthorize")
.WithOpenApi("Auth", "PC 授权码登录,生成本地全局 token。", "", typeof(PcAuthorizeRequest), typeof(PcTokenResponse))
.PcOnly();
endpoints.MapPost<IPcAuthEndpointService>("api/pc/auth/refresh", (service, ctx) => service.RefreshAsync(ctx))
.WithName("PcRefresh")
.WithOpenApi("Auth", "PC 全局 token 刷新。", "", typeof(PcRefreshRequest), typeof(PcTokenResponse))
.PcOnly();
endpoints.MapPost<IPcAuthEndpointService>("api/pc/auth/logout", (service, ctx) => service.LogoutAsync(ctx))
.WithName("PcLogout")
.WithOpenApi("Auth", "PC 退出登录。", "", typeof(PcLogoutRequest))
.PcOnly();
});
}
}
}

View File

@ -0,0 +1,233 @@
using Avalonia_Services.Core;
namespace Avalonia_Services.Extensions
{
/// <summary>
/// Desktop (Avalonia-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 endpoint = _endpoints.Endpoints.FirstOrDefault(e =>
e.SupportsHost(EndpointHostTarget.Pc) &&
string.Equals(e.Pattern, path, StringComparison.OrdinalIgnoreCase) &&
string.Equals(e.HttpMethod, method, StringComparison.OrdinalIgnoreCase));
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),
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);
}
}
}

View File

@ -0,0 +1,98 @@
namespace Avalonia_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);
}
}

View File

@ -0,0 +1,59 @@
using Avalonia_Services.Core;
using System.Threading.Tasks;
namespace Avalonia_Services.Services.AuthService
{
/// <summary>
/// API 鉴权端点服务接口,定义登录、刷新 Token 和登出操作。
/// </summary>
public interface IApiAuthEndpointService
{
/// <summary>
/// 处理用户登录请求。
/// </summary>
/// <param name="ctx">服务端点上下文。</param>
/// <returns>包含 Token 的认证响应。</returns>
Task<object?> LoginAsync(ServiceEndpointContext ctx);
/// <summary>
/// 使用 Refresh Token 刷新 Access Token。
/// </summary>
/// <param name="ctx">服务端点上下文。</param>
/// <returns>新的 Token 对。</returns>
Task<object?> RefreshAsync(ServiceEndpointContext ctx);
/// <summary>
/// 处理用户登出请求。
/// </summary>
/// <param name="ctx">服务端点上下文。</param>
/// <returns>登出结果。</returns>
Task<object?> LogoutAsync(ServiceEndpointContext ctx);
}
/// <summary>
/// PC 端鉴权端点服务接口定义授权码登录、Token 刷新和登出操作。
/// </summary>
public interface IPcAuthEndpointService
{
/// <summary>
/// 使用授权码进行登录授权。
/// </summary>
/// <param name="ctx">服务端点上下文。</param>
/// <returns>包含 Token 的认证响应。</returns>
Task<object?> AuthorizeAsync(ServiceEndpointContext ctx);
/// <summary>
/// 刷新当前 Token。
/// </summary>
/// <param name="ctx">服务端点上下文。</param>
/// <returns>新的 Token 响应。</returns>
Task<object?> RefreshAsync(ServiceEndpointContext ctx);
/// <summary>
/// 处理用户登出请求。
/// </summary>
/// <param name="ctx">服务端点上下文。</param>
/// <returns>登出结果。</returns>
Task<object?> LogoutAsync(ServiceEndpointContext ctx);
}
}

View File

@ -0,0 +1,30 @@
using Avalonia_EFCore.Models;
namespace Avalonia_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();
}
}
}

View File

@ -0,0 +1,8 @@
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
charset = utf-8
indent_size = 2
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
end_of_line = lf
max_line_length = 100

1
Avalonia-Web-VUE/.gitattributes vendored Normal file
View File

@ -0,0 +1 @@
* text=auto eol=lf

39
Avalonia-Web-VUE/.gitignore vendored Normal file
View File

@ -0,0 +1,39 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo
.eslintcache
# Cypress
/cypress/videos/
/cypress/screenshots/
# Vitest
__screenshots__/
# Vite
*.timestamp-*-*.mjs

View File

@ -0,0 +1,10 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["eslint", "typescript", "unicorn", "oxc", "vue"],
"env": {
"browser": true
},
"categories": {
"correctness": "error"
}
}

View File

@ -0,0 +1,13 @@
此文件解释 Visual Studio 如何创建项目。
以下工具用于生成此项目:
- create-vite
以下为生成此项目的步骤:
- 使用 create-vite: `npm init --yes vue@latest avalonia-web -- --eslint --typescript ` 创建 vue 项目。
- 正在使用端口更新 `vite.config.ts`
- 为基本类型添加 `shims-vue.d.ts`
- 创建项目文件 (`avalonia-web.esproj`)。
- 创建 `launch.json` 以启用调试。
- 向解决方案添加项目。
- 写入此文件。

View File

@ -0,0 +1,48 @@
# avalonia-web
This template should help get you started developing with Vue 3 in Vite.
## Recommended IDE Setup
[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
## Recommended Browser Setup
- Chromium-based browsers (Chrome, Edge, Brave, etc.):
- [Vue.js devtools](https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd)
- [Turn on Custom Object Formatter in Chrome DevTools](http://bit.ly/object-formatters)
- Firefox:
- [Vue.js devtools](https://addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/)
- [Turn on Custom Object Formatter in Firefox DevTools](https://fxdx.dev/firefox-devtools-custom-object-formatters/)
## Type Support for `.vue` Imports in TS
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
## Customize configuration
See [Vite Configuration Reference](https://vite.dev/config/).
## Project Setup
```sh
npm install
```
### Compile and Hot-Reload for Development
```sh
npm run dev
```
### Type-Check, Compile and Minify for Production
```sh
npm run build
```
### Lint with [ESLint](https://eslint.org/)
```sh
npm run lint
```

View File

@ -0,0 +1,11 @@
<Project Sdk="Microsoft.VisualStudio.JavaScript.Sdk/1.0.5171056">
<PropertyGroup>
<StartupCommand>npm run dev</StartupCommand>
<JavaScriptTestRoot>.\</JavaScriptTestRoot>
<JavaScriptTestFramework>Vitest</JavaScriptTestFramework>
<!-- Allows the build (or compile) script located on package.json to run on Build -->
<ShouldRunBuildScript>false</ShouldRunBuildScript>
<!-- Folder where production build objects will be placed -->
<BuildOutputFolder>$(MSBuildProjectDirectory)\dist</BuildOutputFolder>
</PropertyGroup>
</Project>

1
Avalonia-Web-VUE/env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

Some files were not shown because too many files have changed in this diff Show More