init
This commit is contained in:
commit
e3fe965f10
13
.config/dotnet-tools.json
Normal file
13
.config/dotnet-tools.json
Normal 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
30
.gitignore
vendored
Normal 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
7
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"chat.tools.terminal.autoApprove": {
|
||||||
|
"ForEach-Object": true,
|
||||||
|
"dotnet list": true,
|
||||||
|
"dotnet build": true
|
||||||
|
}
|
||||||
|
}
|
||||||
160
Avalonia-API/Authentication/ApiAuthEndpointService.cs
Normal file
160
Avalonia-API/Authentication/ApiAuthEndpointService.cs
Normal 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"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
33
Avalonia-API/Authentication/JwtOptions.cs
Normal file
33
Avalonia-API/Authentication/JwtOptions.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
55
Avalonia-API/Authentication/JwtTokenService.cs
Normal file
55
Avalonia-API/Authentication/JwtTokenService.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
124
Avalonia-API/Authentication/RefreshTokenService.cs
Normal file
124
Avalonia-API/Authentication/RefreshTokenService.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
31
Avalonia-API/Avalonia-API.csproj
Normal file
31
Avalonia-API/Avalonia-API.csproj
Normal 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>
|
||||||
9
Avalonia-API/Avalonia-API.csproj.user
Normal file
9
Avalonia-API/Avalonia-API.csproj.user
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<Project ToolsVersion="Current" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||||
|
<PropertyGroup>
|
||||||
|
<ActiveDebugProfile>http</ActiveDebugProfile>
|
||||||
|
</PropertyGroup>
|
||||||
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
||||||
|
<DebuggerFlavor>ProjectDebugger</DebuggerFlavor>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
6
Avalonia-API/Avalonia-API.http
Normal file
6
Avalonia-API/Avalonia-API.http
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
@Avalonia_API_HostAddress = http://localhost:5206
|
||||||
|
|
||||||
|
GET {{Avalonia_API_HostAddress}}/weatherforecast/
|
||||||
|
Accept: application/json
|
||||||
|
|
||||||
|
###
|
||||||
75
Avalonia-API/Configuration/ServicesConfiguration.cs
Normal file
75
Avalonia-API/Configuration/ServicesConfiguration.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
225
Avalonia-API/Extensions/UnifiedEndpointExtensions.cs
Normal file
225
Avalonia-API/Extensions/UnifiedEndpointExtensions.cs
Normal 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
60
Avalonia-API/Program.cs
Normal 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();
|
||||||
|
}
|
||||||
23
Avalonia-API/Properties/launchSettings.json
Normal file
23
Avalonia-API/Properties/launchSettings.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
8
Avalonia-API/appsettings.Development.json
Normal file
8
Avalonia-API/appsettings.Development.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
24
Avalonia-API/appsettings.json
Normal file
24
Avalonia-API/appsettings.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
18
Avalonia-Common/Avalonia-Common.csproj
Normal file
18
Avalonia-Common/Avalonia-Common.csproj
Normal 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>
|
||||||
205
Avalonia-Common/Core/ApiResponse.cs
Normal file
205
Avalonia-Common/Core/ApiResponse.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
167
Avalonia-Common/Infrastructure/LoggingConfiguration.cs
Normal file
167
Avalonia-Common/Infrastructure/LoggingConfiguration.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
28
Avalonia-EFCore/Avalonia-EFCore.csproj
Normal file
28
Avalonia-EFCore/Avalonia-EFCore.csproj
Normal 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>
|
||||||
50
Avalonia-EFCore/Database/AppDataContext.cs
Normal file
50
Avalonia-EFCore/Database/AppDataContext.cs
Normal 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");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
104
Avalonia-EFCore/Database/AppDataContextFactory.cs
Normal file
104
Avalonia-EFCore/Database/AppDataContextFactory.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
115
Avalonia-EFCore/Database/AppDbContext.cs
Normal file
115
Avalonia-EFCore/Database/AppDbContext.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
93
Avalonia-EFCore/Database/DatabaseConfiguration.cs
Normal file
93
Avalonia-EFCore/Database/DatabaseConfiguration.cs
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
85
Avalonia-EFCore/Database/DatabaseExtensions.cs
Normal file
85
Avalonia-EFCore/Database/DatabaseExtensions.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
224
Avalonia-EFCore/Database/DatabaseManager.cs
Normal file
224
Avalonia-EFCore/Database/DatabaseManager.cs
Normal 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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
59
Avalonia-EFCore/Database/DatabaseProviderRegistry.cs
Normal file
59
Avalonia-EFCore/Database/DatabaseProviderRegistry.cs
Normal 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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
30
Avalonia-EFCore/Database/ProviderAppDataContexts.cs
Normal file
30
Avalonia-EFCore/Database/ProviderAppDataContexts.cs
Normal 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)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
175
Avalonia-EFCore/Migrations/MySQL/20260520082626_AutoMigration_20260520162543.Designer.cs
generated
Normal file
175
Avalonia-EFCore/Migrations/MySQL/20260520082626_AutoMigration_20260520162543.Designer.cs
generated
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
181
Avalonia-EFCore/Migrations/MySQL/20260520083306_AutoMigration_20260520163216.Designer.cs
generated
Normal file
181
Avalonia-EFCore/Migrations/MySQL/20260520083306_AutoMigration_20260520163216.Designer.cs
generated
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
184
Avalonia-EFCore/Migrations/PostgreSQL/20260520082617_AutoMigration_20260520162543.Designer.cs
generated
Normal file
184
Avalonia-EFCore/Migrations/PostgreSQL/20260520082617_AutoMigration_20260520162543.Designer.cs
generated
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
190
Avalonia-EFCore/Migrations/PostgreSQL/20260520083254_AutoMigration_20260520163216.Designer.cs
generated
Normal file
190
Avalonia-EFCore/Migrations/PostgreSQL/20260520083254_AutoMigration_20260520163216.Designer.cs
generated
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
94
Avalonia-EFCore/Migrations/SQLite/20260514000100_InitialCreate.Designer.cs
generated
Normal file
94
Avalonia-EFCore/Migrations/SQLite/20260514000100_InitialCreate.Designer.cs
generated
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
113
Avalonia-EFCore/Migrations/SQLite/20260515072045_AutoMigration_20260515152037.Designer.cs
generated
Normal file
113
Avalonia-EFCore/Migrations/SQLite/20260515072045_AutoMigration_20260515152037.Designer.cs
generated
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
173
Avalonia-EFCore/Migrations/SQLite/20260515085847_AutoMigration_20260515165835.Designer.cs
generated
Normal file
173
Avalonia-EFCore/Migrations/SQLite/20260515085847_AutoMigration_20260515165835.Designer.cs
generated
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
179
Avalonia-EFCore/Migrations/SQLite/20260520083230_AutoMigration_20260520163216.Designer.cs
generated
Normal file
179
Avalonia-EFCore/Migrations/SQLite/20260520083230_AutoMigration_20260520163216.Designer.cs
generated
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
184
Avalonia-EFCore/Migrations/SqlServer/20260520082607_AutoMigration_20260520162543.Designer.cs
generated
Normal file
184
Avalonia-EFCore/Migrations/SqlServer/20260520082607_AutoMigration_20260520162543.Designer.cs
generated
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
190
Avalonia-EFCore/Migrations/SqlServer/20260520083242_AutoMigration_20260520163216.Designer.cs
generated
Normal file
190
Avalonia-EFCore/Migrations/SqlServer/20260520083242_AutoMigration_20260520163216.Designer.cs
generated
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
79
Avalonia-EFCore/Models/ApiRefreshTokenEntity.cs
Normal file
79
Avalonia-EFCore/Models/ApiRefreshTokenEntity.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
69
Avalonia-EFCore/Models/UserEntity.cs
Normal file
69
Avalonia-EFCore/Models/UserEntity.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
28
Avalonia-EFCore/Models/WeatherForecast.cs
Normal file
28
Avalonia-EFCore/Models/WeatherForecast.cs
Normal 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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
59
Avalonia-EFCore/Models/WeatherForecastEntity.cs
Normal file
59
Avalonia-EFCore/Models/WeatherForecastEntity.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
4
Avalonia-PC/.github/copilot-instructions.md
vendored
Normal file
4
Avalonia-PC/.github/copilot-instructions.md
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
# Copilot Instructions
|
||||||
|
|
||||||
|
## 项目指南
|
||||||
|
- 用户偏好:仅修改明确要求的内容,不要做额外改动(如未请求的 ViewModel DI 注册)。
|
||||||
15
Avalonia-PC/App.axaml
Normal file
15
Avalonia-PC/App.axaml
Normal 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
37
Avalonia-PC/App.axaml.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
Avalonia-PC/Assets/avalonia-logo.ico
Normal file
BIN
Avalonia-PC/Assets/avalonia-logo.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 172 KiB |
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
94
Avalonia-PC/Authentication/PcAuthEndpointService.cs
Normal file
94
Avalonia-PC/Authentication/PcAuthEndpointService.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
60
Avalonia-PC/Authentication/PcAuthService.cs
Normal file
60
Avalonia-PC/Authentication/PcAuthService.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
227
Avalonia-PC/Authentication/PcGlobalTokenService.cs
Normal file
227
Avalonia-PC/Authentication/PcGlobalTokenService.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
48
Avalonia-PC/Avalonia-PC.csproj
Normal file
48
Avalonia-PC/Avalonia-PC.csproj
Normal 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>
|
||||||
9
Avalonia-PC/Avalonia-PC.csproj.user
Normal file
9
Avalonia-PC/Avalonia-PC.csproj.user
Normal 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>
|
||||||
11
Avalonia-PC/Avalonia-PC.slnx
Normal file
11
Avalonia-PC/Avalonia-PC.slnx
Normal 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
97
Avalonia-PC/Program.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
11
Avalonia-PC/Properties/launchSettings.json
Normal file
11
Avalonia-PC/Properties/launchSettings.json
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"profiles": {
|
||||||
|
"Avalonia-PC": {
|
||||||
|
"commandName": "Project"
|
||||||
|
},
|
||||||
|
"WSL": {
|
||||||
|
"commandName": "WSL2",
|
||||||
|
"distributionName": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
53
Avalonia-PC/ViewLocator.cs
Normal file
53
Avalonia-PC/ViewLocator.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
13
Avalonia-PC/ViewModels/MainWindowViewModel.cs
Normal file
13
Avalonia-PC/ViewModels/MainWindowViewModel.cs
Normal 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!";
|
||||||
|
}
|
||||||
|
}
|
||||||
12
Avalonia-PC/ViewModels/ViewModelBase.cs
Normal file
12
Avalonia-PC/ViewModels/ViewModelBase.cs
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
|
||||||
|
namespace Avalonia_PC.ViewModels
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// ViewModel 基类,继承自 CommunityToolkit.Mvvm 的 ObservableObject,
|
||||||
|
/// 提供属性变更通知功能。
|
||||||
|
/// </summary>
|
||||||
|
public abstract class ViewModelBase : ObservableObject
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
345
Avalonia-PC/Views/MainWindow.BridgeScript.cs
Normal file
345
Avalonia-PC/Views/MainWindow.BridgeScript.cs
Normal 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;
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
}
|
||||||
|
}
|
||||||
38
Avalonia-PC/Views/MainWindow.Routes.cs
Normal file
38
Avalonia-PC/Views/MainWindow.Routes.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
23
Avalonia-PC/Views/MainWindow.axaml
Normal file
23
Avalonia-PC/Views/MainWindow.axaml
Normal 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>
|
||||||
684
Avalonia-PC/Views/MainWindow.axaml.cs
Normal file
684
Avalonia-PC/Views/MainWindow.axaml.cs
Normal 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
18
Avalonia-PC/app.manifest
Normal 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
51
Avalonia-PC/www/api.js
Normal 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 } }),
|
||||||
|
};
|
||||||
62
Avalonia-PC/www/index.html
Normal file
62
Avalonia-PC/www/index.html
Normal 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>
|
||||||
24
Avalonia-Services/Avalonia-Services.csproj
Normal file
24
Avalonia-Services/Avalonia-Services.csproj
Normal 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>
|
||||||
70
Avalonia-Services/Core/EndpointPrinter.cs
Normal file
70
Avalonia-Services/Core/EndpointPrinter.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
107
Avalonia-Services/Core/GlobalExceptionFilter.cs
Normal file
107
Avalonia-Services/Core/GlobalExceptionFilter.cs
Normal 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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
39
Avalonia-Services/Core/IAuthService.cs
Normal file
39
Avalonia-Services/Core/IAuthService.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
48
Avalonia-Services/Core/IEndpointFilter.cs
Normal file
48
Avalonia-Services/Core/IEndpointFilter.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
364
Avalonia-Services/Core/ServiceEndpointCollection.cs
Normal file
364
Avalonia-Services/Core/ServiceEndpointCollection.cs
Normal 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-API(ASP.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
79
Avalonia-Services/Core/ServiceEndpointContext.cs
Normal file
79
Avalonia-Services/Core/ServiceEndpointContext.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
147
Avalonia-Services/Endpoints/AppEndpoints.cs
Normal file
147
Avalonia-Services/Endpoints/AppEndpoints.cs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
61
Avalonia-Services/Endpoints/AuthEndpoints.cs
Normal file
61
Avalonia-Services/Endpoints/AuthEndpoints.cs
Normal 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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
233
Avalonia-Services/Extensions/DesktopEndpointAdapter.cs
Normal file
233
Avalonia-Services/Extensions/DesktopEndpointAdapter.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
98
Avalonia-Services/Services/AuthService/AuthContracts.cs
Normal file
98
Avalonia-Services/Services/AuthService/AuthContracts.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
30
Avalonia-Services/Services/WeatherForecastService.cs
Normal file
30
Avalonia-Services/Services/WeatherForecastService.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
8
Avalonia-Web-VUE/.editorconfig
Normal file
8
Avalonia-Web-VUE/.editorconfig
Normal 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
1
Avalonia-Web-VUE/.gitattributes
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
39
Avalonia-Web-VUE/.gitignore
vendored
Normal file
39
Avalonia-Web-VUE/.gitignore
vendored
Normal 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
|
||||||
10
Avalonia-Web-VUE/.oxlintrc.json
Normal file
10
Avalonia-Web-VUE/.oxlintrc.json
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||||
|
"plugins": ["eslint", "typescript", "unicorn", "oxc", "vue"],
|
||||||
|
"env": {
|
||||||
|
"browser": true
|
||||||
|
},
|
||||||
|
"categories": {
|
||||||
|
"correctness": "error"
|
||||||
|
}
|
||||||
|
}
|
||||||
13
Avalonia-Web-VUE/CHANGELOG.md
Normal file
13
Avalonia-Web-VUE/CHANGELOG.md
Normal 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` 以启用调试。
|
||||||
|
- 向解决方案添加项目。
|
||||||
|
- 写入此文件。
|
||||||
48
Avalonia-Web-VUE/README.md
Normal file
48
Avalonia-Web-VUE/README.md
Normal 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
|
||||||
|
```
|
||||||
11
Avalonia-Web-VUE/avalonia-web-vue.esproj
Normal file
11
Avalonia-Web-VUE/avalonia-web-vue.esproj
Normal 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
1
Avalonia-Web-VUE/env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user