增加测试

This commit is contained in:
chfei committed 2026-10-28 11:35:48 +08:00
1 parent 6d4629e4ac
commit 497e9f1c34
14 files changed
+700 -74

No files matched your search

@@ -7,6 +7,7 @@ namespace Store.Management.Api.Authentication;
public sealed class LoginRequest
{
[Required] public string UserName { get; set; } = "";
/// <summary>服务端要求的 UTF-8 密码的 32 位大写 MD5,由调用方计算。</summary>
[Required] public string Password { get; set; } = "";
[StringLength(100)] public string? DeviceId { get; set; }
[StringLength(100)] public string? DeviceName { get; set; }
@@ -39,27 +40,38 @@ internal sealed class AuthApi(ApiTransport transport, TokenSession session) : IA
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
var result = await transport.GetRequiredAsync<LoginResult>("Auth/Login", Method.Post, request,
LoginResult 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(); }
finally
{
_gate.Release();
}
}
public async Task LogoutAsync(CancellationToken cancellationToken = default)
{
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
var token = session.Token;
string? 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();
}
finally { session.ClearIfCurrent(token); _gate.Release(); }
}
public Task<CurrentUser> MeAsync(CancellationToken cancellationToken = default)
+6 -6
View File
@@ -1,6 +1,6 @@
# Store Management API 客户端
独立的 net10.0 类库,使用 RestSharp 112.1.0 连接 StoreWeb。WPF 项目已引用该类库。每个服务地址及用户会话创建一个 StoreApiClient,建议在应用容器注册为单例,退出应用时释放。
独立的 API 类库(当前继承仓库的 net8.0-windows 目标框架),使用 RestSharp 112.1.0 连接 StoreWeb。WPF 项目已引用该类库。每个服务地址及用户会话创建一个 StoreApiClient,建议在应用容器注册为单例,退出应用时释放。
## 调用
@@ -21,7 +21,7 @@ try
await api.Auth.LoginAsync(new LoginRequest
{
UserName = userName,
Password = password,
Password = passwordMd5, // UTF-8 密码的 32 位大写 MD5,保留原密码首尾空格
DeviceId = deviceId, // 可选;稳定设备标识由宿主应用管理
DeviceName = Environment.MachineName
});
@@ -44,7 +44,7 @@ catch (ApiException ex)
}
```
示例中的账号、密码、设备标识来自调用方输入。异步方法均支持 CancellationToken;主动取消抛出 OperationCanceledException,本地输入校验抛出 ValidationException。
示例中的账号、密码摘要、设备标识由调用方提供;登录窗口已负责计算密码摘要,API 层不重复计算。异步方法均支持 CancellationToken;主动取消抛出 OperationCanceledException,本地输入校验抛出 ValidationException。
## 文件与职责
@@ -58,13 +58,13 @@ catch (ApiException ex)
## 协议与扩展
对齐 StoreWeb 的 AuthController、TokenResult、UnifyResultFilter 和用户管理控制器。成功码为 200;HTTP 200 内的业务失败同样抛出 ApiException。登录读取 data.token,认证采用 Bearer opaque Token,不解析 JWT,不根据首次登录的有效期自行判定续期。HTTP 或业务 401 清除当前请求所用的会话;旧请求的 401 不清除新 Token。
对齐 StoreWeb 的 AuthController、TokenResult、UnifyResultFilter 和用户管理控制器。成功码为 200;HTTP 200 内的业务失败同样抛出 ApiException。先读取响应状态与业务码,再解析成功 data,避免错误响应的数据结构掩盖业务错误。登录读取 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 磁盘持久化、自动刷新或商品接口。实际地址由宿主提供,尚未进行真实服务联调。
登录界面由宿主项目提供;API 层不包含 Token 磁盘持久化、自动刷新或商品接口。实际地址由宿主提供,尚未进行真实服务联调。
## 验证
@@ -75,4 +75,4 @@ dotnet build Store.Management.slnx
dotnet run --project Tests/Store.Management.Api.SmokeTests/Store.Management.Api.SmokeTests.csproj
```
模拟 HTTP 测试覆盖登录路由、Bearer、分页序列化与解析、输入边界、业务错误、无效 JSON、403、HTTP 与业务 401、退出及主动取消,不会调用真实服务。
模拟 HTTP 测试覆盖登录路由、Bearer、分页序列化与解析、输入边界、业务错误、无效 JSON、403、HTTP 与业务 401、无效 Token/data、旧请求 401 会话隔离、登录退出串行、退出失败清理及主动取消,不会调用真实服务。
@@ -20,7 +20,7 @@ public sealed class ApiTransport : IDisposable
options.Validate();
_session = session;
_version = options.Version;
var settings = new RestClientOptions(new Uri(options.BaseUrl.AbsoluteUri.TrimEnd('/') + "/"))
RestClientOptions settings = new RestClientOptions(new Uri(options.BaseUrl.AbsoluteUri.TrimEnd('/') + "/"))
{
Timeout = options.Timeout,
FollowRedirects = false
@@ -32,30 +32,62 @@ public sealed class ApiTransport : IDisposable
IReadOnlyDictionary<string, string?>? query = null, bool authenticated = true,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
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 (body is not null)
{
Validator.ValidateObject(body, new ValidationContext(body), true);
}
string? token = authenticated ? _session.Token : null;
if (authenticated && token is null)
{
throw new ApiException(ApiErrorKind.Unauthorized, "请先登录。", code: 401);
}
RestRequest 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);
{
foreach (KeyValuePair<string, string?> pair in query)
{
if (pair.Value is not null)
{
request.AddQueryParameter(pair.Key, pair.Value);
}
}
}
RestResponse 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;
// 先判断状态,再解析 data;错误响应的数据结构可能与成功响应不同。
ApiResult<JsonElement>? result = null;
JsonException? parseError = null;
try { result = JsonSerializer.Deserialize<ApiResult<T>>(response.Content ?? "", Json); }
catch (JsonException ex) { parseError = ex; }
try
{
result = JsonSerializer.Deserialize<ApiResult<JsonElement>>(response.Content ?? "", Json);
}
catch (JsonException ex)
{
parseError = ex;
}
if (response.StatusCode == HttpStatusCode.Unauthorized || result?.Code == 401)
{
_session.ClearIfCurrent(token);
@@ -63,13 +95,31 @@ public sealed class ApiTransport : IDisposable
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;
}
if (result.Data.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined)
{
return default;
}
try
{
return result.Data.Deserialize<T>(Json);
}
catch (JsonException ex)
{
throw new ApiException(ApiErrorKind.InvalidResponse, "服务器响应 data 格式无效。",
response.StatusCode, result.Code, result.TraceId, ex);
}
}
public async Task<T> GetRequiredAsync<T>(string route, Method method, object? body = null,