更新框架

This commit is contained in:
chfei committed 2026-10-27 14:35:03 +08:00
1 parent ef50fa0620
commit 84b28f9672
72 files changed
+2222 -282

No files matched your search

@@ -0,0 +1,19 @@
namespace Store.Management.Api;
public sealed class ApiClientOptions
{
public required Uri BaseUrl { get; init; }
public string Version { get; init; } = "v1";
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(30);
internal void Validate()
{
if (BaseUrl is null || !BaseUrl.IsAbsoluteUri ||
(BaseUrl.Scheme != "https" && BaseUrl.Scheme != "http") ||
!string.IsNullOrEmpty(BaseUrl.UserInfo) || !string.IsNullOrEmpty(BaseUrl.Query) || !string.IsNullOrEmpty(BaseUrl.Fragment))
throw new ArgumentException("BaseUrl 必须是无凭据、查询参数和片段的 HTTP(S) 绝对地址。");
if (!System.Text.RegularExpressions.Regex.IsMatch(Version, @"^v\d+(\.\d+)?$"))
throw new ArgumentException("Version 格式应为 v1 或 v1.0。");
if (Timeout <= TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(Timeout));
}
}
@@ -0,0 +1,67 @@
using System.ComponentModel.DataAnnotations;
using RestSharp;
using Store.Management.Api.Transport;
namespace Store.Management.Api.Authentication;
public sealed class LoginRequest
{
[Required] public string UserName { get; set; } = "";
[Required] public string Password { get; set; } = "";
[StringLength(100)] public string? DeviceId { get; set; }
[StringLength(100)] public string? DeviceName { get; set; }
}
public sealed class LoginResult
{
public string TokenType { get; init; } = "Bearer";
public string Token { get; init; } = "";
public uint ExpiresInMinutes { get; init; }
}
public sealed record CurrentUser(string UserName, string? NickName);
public interface IAuthApi
{
Task<LoginResult> LoginAsync(LoginRequest request, CancellationToken cancellationToken = default);
Task LogoutAsync(CancellationToken cancellationToken = default);
Task<CurrentUser> MeAsync(CancellationToken cancellationToken = default);
}
internal sealed class AuthApi(ApiTransport transport, TokenSession session) : IAuthApi
{
// 登录和退出串行执行,避免迟到的登录响应覆盖退出操作。
private readonly SemaphoreSlim _gate = new(1, 1);
public async Task<LoginResult> LoginAsync(LoginRequest request, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
var result = await transport.GetRequiredAsync<LoginResult>("Auth/Login", Method.Post, request,
authenticated: false, cancellationToken: cancellationToken).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(result.Token) || result.Token.Contains('\r') || result.Token.Contains('\n') ||
!string.Equals(result.TokenType, "Bearer", StringComparison.OrdinalIgnoreCase))
throw new ApiException(ApiErrorKind.InvalidResponse, "登录响应缺少有效的 Bearer Token。");
session.Set(result.Token);
return result;
}
finally { _gate.Release(); }
}
public async Task LogoutAsync(CancellationToken cancellationToken = default)
{
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
var token = session.Token;
try
{
if (token is not null)
await transport.SendAsync<object>("Auth/Logout", Method.Post, cancellationToken: cancellationToken).ConfigureAwait(false);
}
finally { session.ClearIfCurrent(token); _gate.Release(); }
}
public Task<CurrentUser> MeAsync(CancellationToken cancellationToken = default)
=> transport.GetRequiredAsync<CurrentUser>("Auth/Me", Method.Get, cancellationToken: cancellationToken);
}
@@ -0,0 +1,16 @@
namespace Store.Management.Api.Authentication;
/// <summary>会话仅保存在内存中,不将密码或 Token 写入配置与日志。</summary>
public sealed class TokenSession
{
private string? _token;
public bool IsAuthenticated => Volatile.Read(ref _token) is not null;
internal string? Token => Volatile.Read(ref _token);
internal void Set(string token) => Interlocked.Exchange(ref _token, token);
public void Clear() => Interlocked.Exchange(ref _token, null);
// 旧请求的 401 不应清除重新登录后建立的新会话。
internal void ClearIfCurrent(string? token)
{
if (token is not null) Interlocked.CompareExchange(ref _token, null, token);
}
}
@@ -0,0 +1,19 @@
namespace Store.Management.Api.Models;
public sealed class ApiResult<T>
{
public required int Code { get; init; }
public string? Msg { get; init; }
public string? TraceId { get; init; }
public T? Data { get; init; }
}
public sealed record PageResult<T>(long Total, IReadOnlyList<T> Items);
public class PageRequest
{
[System.ComponentModel.DataAnnotations.Range(1, 1000000)]
public int Page { get; set; } = 1;
[System.ComponentModel.DataAnnotations.Range(1, 200)]
public int PageSize { get; set; } = 50;
}
+78
View File
@@ -0,0 +1,78 @@
# Store Management API 客户端
独立的 net10.0 类库,使用 RestSharp 112.1.0 连接 StoreWeb。WPF 项目已引用该类库。每个服务地址及用户会话创建一个 StoreApiClient,建议在应用容器注册为单例,退出应用时释放。
## 调用
```csharp
using Store.Management.Api;
using Store.Management.Api.Authentication;
using Store.Management.Api.Users;
using Store.Management.Api.Transport;
using var api = new StoreApiClient(new ApiClientOptions
{
BaseUrl = new Uri("https://your-server/"), // 替换为实际服务地址
Version = "v1",
Timeout = TimeSpan.FromSeconds(30)
});
try
{
await api.Auth.LoginAsync(new LoginRequest
{
UserName = userName,
Password = password,
DeviceId = deviceId, // 可选;稳定设备标识由宿主应用管理
DeviceName = Environment.MachineName
});
var me = await api.Auth.MeAsync();
var page = await api.Users.SearchAsync(new UserSearchRequest
{
Page = 1, PageSize = 50, Keyword = ""
});
var created = await api.Users.CreateAsync(new UserCreateRequest
{
UserName = "new-user", Password = initialPassword, NickName = "新用户"
});
await api.Users.UpdateAsync(new UserUpdateRequest { Id = created.Id, NickName = "修改后的昵称" });
await api.Auth.LogoutAsync();
}
catch (ApiException ex)
{
// 显示 ex.Message;根据 Kind 区分失败类型,TraceId 可用于联系服务端排查。
// Unauthorized 时导航登录页;HTTP 403 不清除会话。
}
```
示例中的账号、密码、设备标识来自调用方输入。异步方法均支持 CancellationToken;主动取消抛出 OperationCanceledException,本地输入校验抛出 ValidationException。
## 文件与职责
- StoreApiClient.cs:组合会话、传输层与业务模块,集中管理生命周期。
- ApiClientOptions.cs:服务地址、版本和超时配置。
- Transport/ApiTransport.cs:唯一 HTTP 出口,统一 Bearer、JSON、查询参数、校验及错误映射。
- Transport/ApiException.cs:区分网络、超时、HTTP、业务、响应格式与认证错误,保留状态码、业务码与 TraceId。
- Authentication/:登录、退出、当前用户;Token 仅存内存。
- Models/:服务端响应信封与分页模型。
- Users/:用户分页、查询、添加与修改,DTO 只包含当前基础功能所需字段。
## 协议与扩展
对齐 StoreWeb 的 AuthController、TokenResult、UnifyResultFilter 和用户管理控制器。成功码为 200;HTTP 200 内的业务失败同样抛出 ApiException。登录读取 data.token,认证采用 Bearer opaque Token,不解析 JWT,不根据首次登录的有效期自行判定续期。HTTP 或业务 401 清除当前请求所用的会话;旧请求的 401 不清除新 Token。
后续商品模块按 Users 的方式新增独立 DTO、接口和实现,由 StoreApiClient 暴露模块入口。模块通过 Transport.GetRequiredAsync<T> 发送有数据请求,通过 SendAsync<object> 发送无数据操作。路由采用 user/GetAllUser 这样的相对路径,传输层自动添加版本。简单参数使用 query,复杂参数使用 body,HTTP 方法与服务端契约一致。避免按常规 REST 习惯猜测当前服务端的动作路由。
请求不自动重试,避免添加、修改操作重复执行。禁止自动重定向;服务地址应直接指向最终 API 地址。退出时即使服务端请求失败也清理本地 Token,并将错误交给宿主;此时服务端会话可能仍有效。Session.Clear 可用于本地退出,不撤销服务器会话。
当前不包含登录界面、Token 磁盘持久化、自动刷新或商品接口。实际地址由宿主提供,尚未进行真实服务联调。
## 验证
在仓库根目录运行:
```powershell
dotnet build Store.Management.slnx
dotnet run --project Tests/Store.Management.Api.SmokeTests/Store.Management.Api.SmokeTests.csproj
```
模拟 HTTP 测试覆盖登录路由、Bearer、分页序列化与解析、输入边界、业务错误、无效 JSON、403、HTTP 与业务 401、退出及主动取消,不会调用真实服务。
@@ -0,0 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="RestSharp" Version="112.1.0" />
</ItemGroup>
</Project>
@@ -0,0 +1,29 @@
using Store.Management.Api.Authentication;
using Store.Management.Api.Transport;
using Store.Management.Api.Users;
namespace Store.Management.Api;
/// <summary>一个服务地址与一个用户会话对应一个实例;在应用容器中注册为单例。</summary>
public sealed class StoreApiClient : IDisposable
{
public TokenSession Session { get; } = new();
public ApiTransport Transport { get; }
public IAuthApi Auth { get; }
public IUserApi Users { get; }
/// <param name="httpClient">可选的外部 HTTP 客户端,由调用方管理其生命周期。</param>
public StoreApiClient(ApiClientOptions options, HttpClient? httpClient = null)
{
ArgumentNullException.ThrowIfNull(options);
Transport = new ApiTransport(options, Session, httpClient);
Auth = new AuthApi(Transport, Session);
Users = new UserApi(Transport);
}
public void Dispose()
{
Session.Clear();
Transport.Dispose();
}
}
@@ -0,0 +1,18 @@
using System.Net;
namespace Store.Management.Api.Transport;
public enum ApiErrorKind { Network, Timeout, Http, Business, InvalidResponse, Unauthorized }
public sealed class ApiException : Exception
{
public ApiErrorKind Kind { get; }
public HttpStatusCode? StatusCode { get; }
public int? Code { get; }
public string? TraceId { get; }
public ApiException(ApiErrorKind kind, string message, HttpStatusCode? statusCode = null,
int? code = null, string? traceId = null, Exception? innerException = null)
: base(message, innerException)
=> (Kind, StatusCode, Code, TraceId) = (kind, statusCode, code, traceId);
}
@@ -0,0 +1,82 @@
using System.ComponentModel.DataAnnotations;
using System.Net;
using System.Text.Json;
using RestSharp;
using Store.Management.Api.Authentication;
using Store.Management.Api.Models;
namespace Store.Management.Api.Transport;
/// <summary>唯一 HTTP 出口;模块仅指定相对路由、请求数据与响应类型。</summary>
public sealed class ApiTransport : IDisposable
{
private readonly RestClient _client;
private readonly TokenSession _session;
private readonly string _version;
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
internal ApiTransport(ApiClientOptions options, TokenSession session, HttpClient? httpClient)
{
options.Validate();
_session = session;
_version = options.Version;
var settings = new RestClientOptions(new Uri(options.BaseUrl.AbsoluteUri.TrimEnd('/') + "/"))
{
Timeout = options.Timeout,
FollowRedirects = false
};
_client = httpClient is null ? new RestClient(settings) : new RestClient(httpClient, settings, disposeHttpClient: false);
}
public async Task<T?> SendAsync<T>(string route, Method method, object? body = null,
IReadOnlyDictionary<string, string?>? query = null, bool authenticated = true,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(route) || route.StartsWith('/') || route.Contains("..") ||
route.Contains(':') || route.Contains('?') || route.Contains('#') || route.Contains('\\'))
throw new ArgumentException("接口必须使用相对路由,例如 user/GetAllUser。", nameof(route));
if (body is not null) Validator.ValidateObject(body, new ValidationContext(body), true);
cancellationToken.ThrowIfCancellationRequested();
var token = authenticated ? _session.Token : null;
if (authenticated && token is null) throw new ApiException(ApiErrorKind.Unauthorized, "请先登录。", code: 401);
var request = new RestRequest($"{_version}/{route}", method);
if (token is not null) request.AddHeader("Authorization", $"Bearer {token}");
if (body is not null) request.AddJsonBody(body);
if (query is not null)
foreach (var pair in query)
if (pair.Value is not null) request.AddQueryParameter(pair.Key, pair.Value);
var response = await _client.ExecuteAsync(request, cancellationToken).ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
if (response.ResponseStatus == ResponseStatus.TimedOut)
throw new ApiException(ApiErrorKind.Timeout, "请求超时,请稍后重试。", innerException: response.ErrorException);
if ((int)response.StatusCode == 0)
throw new ApiException(ApiErrorKind.Network, "无法连接服务器,请检查网络与服务地址。", innerException: response.ErrorException);
ApiResult<T>? result = null;
JsonException? parseError = null;
try { result = JsonSerializer.Deserialize<ApiResult<T>>(response.Content ?? "", Json); }
catch (JsonException ex) { parseError = ex; }
if (response.StatusCode == HttpStatusCode.Unauthorized || result?.Code == 401)
{
_session.ClearIfCurrent(token);
throw new ApiException(ApiErrorKind.Unauthorized, result?.Msg ?? "登录已失效,请重新登录。",
response.StatusCode, 401, result?.TraceId);
}
if (!response.IsSuccessful)
throw new ApiException(ApiErrorKind.Http, result?.Msg ?? $"服务器请求失败({(int)response.StatusCode})。",
response.StatusCode, result?.Code, result?.TraceId);
if (result is null)
throw new ApiException(ApiErrorKind.InvalidResponse, "服务器响应格式无效。", response.StatusCode, innerException: parseError);
if (result.Code != 200)
throw new ApiException(ApiErrorKind.Business, result.Msg ?? "操作失败。", response.StatusCode, result.Code, result.TraceId);
return result.Data;
}
public async Task<T> GetRequiredAsync<T>(string route, Method method, object? body = null,
IReadOnlyDictionary<string, string?>? query = null, bool authenticated = true, CancellationToken cancellationToken = default)
where T : class
=> await SendAsync<T>(route, method, body, query, authenticated, cancellationToken).ConfigureAwait(false)
?? throw new ApiException(ApiErrorKind.InvalidResponse, "服务器响应缺少 data。");
public void Dispose() => _client.Dispose();
}
@@ -0,0 +1,38 @@
using RestSharp;
using Store.Management.Api.Models;
using Store.Management.Api.Transport;
namespace Store.Management.Api.Users;
public interface IUserApi
{
Task<PageResult<UserInfo>> SearchAsync(UserSearchRequest request, CancellationToken cancellationToken = default);
Task<UserInfo> GetAsync(string? userName = null, CancellationToken cancellationToken = default);
Task<UserInfo> CreateAsync(UserCreateRequest request, CancellationToken cancellationToken = default);
Task<UserInfo> UpdateAsync(UserUpdateRequest request, CancellationToken cancellationToken = default);
}
internal sealed class UserApi(ApiTransport transport) : IUserApi
{
public Task<PageResult<UserInfo>> SearchAsync(UserSearchRequest request, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
return transport.GetRequiredAsync<PageResult<UserInfo>>("user/GetAllUser", Method.Post, request, cancellationToken: cancellationToken);
}
public Task<UserInfo> GetAsync(string? userName = null, CancellationToken cancellationToken = default)
=> transport.GetRequiredAsync<UserInfo>("user/GetUser", Method.Post,
query: new Dictionary<string, string?> { ["userName"] = userName }, cancellationToken: cancellationToken);
public Task<UserInfo> CreateAsync(UserCreateRequest request, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
return transport.GetRequiredAsync<UserInfo>("user/AddUser", Method.Post, request, cancellationToken: cancellationToken);
}
public Task<UserInfo> UpdateAsync(UserUpdateRequest request, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
return transport.GetRequiredAsync<UserInfo>("user/UpdateUser", Method.Post, request, cancellationToken: cancellationToken);
}
}
@@ -0,0 +1,46 @@
using System.ComponentModel.DataAnnotations;
using Store.Management.Api.Models;
namespace Store.Management.Api.Users;
public sealed class UserSearchRequest : PageRequest
{
[StringLength(100)] public string? Keyword { get; set; }
public int? GroupId { get; set; }
[StringLength(20)] public string? ParentUserName { get; set; }
}
public class UserEditRequest
{
[StringLength(50)] public string? NickName { get; set; }
[StringLength(30)] public string? Mobile { get; set; }
[StringLength(100)] public string? CompanyName { get; set; }
[StringLength(255)] public string? Notes { get; set; }
}
public sealed class UserCreateRequest : UserEditRequest
{
[Required, StringLength(20)] public string UserName { get; set; } = "";
[Required, StringLength(128, MinimumLength = 6)] public string Password { get; set; } = "";
[StringLength(20)] public string? ParentUserName { get; set; }
}
public sealed class UserUpdateRequest : UserEditRequest
{
[Range(1, int.MaxValue)] public int Id { get; set; }
}
/// <summary>管理端当前使用的用户资料字段;不接收密码。</summary>
public sealed class UserInfo
{
public int Id { get; init; }
public string UserName { get; init; } = "";
public string? NickName { get; init; }
public string? Mobile { get; init; }
public string? CompanyName { get; init; }
public string? Notes { get; init; }
public string? ParentUserName { get; init; }
public int GroupId { get; init; }
public decimal Points { get; init; }
public decimal? Balance { get; init; }
}