diff --git a/AGENTS.md b/AGENTS.md index ac793c3..3031cba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,6 +4,10 @@ 规则文件使用 `.md` 格式,按以下入口要求加载。 +## 主要编码规范 + +入口只校验一次,拒绝重复判空和滥用try/catch;单次逻辑不乱抽函数,低概率防御先征求同意。 + ## 常驻规则 开始任务前,读取并遵循: diff --git a/DesignDemo/check.cjs b/DesignDemo/check.cjs new file mode 100644 index 0000000..460d0c6 --- /dev/null +++ b/DesignDemo/check.cjs @@ -0,0 +1,41 @@ +const fs = require('node:fs'); +const vm = require('node:vm'); +const assert = require('node:assert/strict'); +const html = fs.readFileSync(__dirname + '/index.html', 'utf8'); +const script = html.match(/ + + diff --git a/Source/Store.Management.Api/Authentication/AuthApi.cs b/Source/Store.Management.Api/Authentication/AuthApi.cs index 85a2772..096d892 100644 --- a/Source/Store.Management.Api/Authentication/AuthApi.cs +++ b/Source/Store.Management.Api/Authentication/AuthApi.cs @@ -7,6 +7,7 @@ namespace Store.Management.Api.Authentication; public sealed class LoginRequest { [Required] public string UserName { get; set; } = ""; + /// 服务端要求的 UTF-8 密码的 32 位大写 MD5,由调用方计算。 [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("Auth/Login", Method.Post, request, + LoginResult result = await transport.GetRequiredAsync("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("Auth/Logout", Method.Post, cancellationToken: cancellationToken).ConfigureAwait(false); + } + } + finally + { + session.ClearIfCurrent(token); + _gate.Release(); } - finally { session.ClearIfCurrent(token); _gate.Release(); } } public Task MeAsync(CancellationToken cancellationToken = default) diff --git a/Source/Store.Management.Api/README.md b/Source/Store.Management.Api/README.md index a19be61..60b1cf2 100644 --- a/Source/Store.Management.Api/README.md +++ b/Source/Store.Management.Api/README.md @@ -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 发送有数据请求,通过 SendAsync 发送无数据操作。路由采用 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 会话隔离、登录退出串行、退出失败清理及主动取消,不会调用真实服务。 diff --git a/Source/Store.Management.Api/Transport/ApiTransport.cs b/Source/Store.Management.Api/Transport/ApiTransport.cs index a7a8dad..c43367a 100644 --- a/Source/Store.Management.Api/Transport/ApiTransport.cs +++ b/Source/Store.Management.Api/Transport/ApiTransport.cs @@ -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? 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 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? result = null; + // 先判断状态,再解析 data;错误响应的数据结构可能与成功响应不同。 + ApiResult? result = null; JsonException? parseError = null; - try { result = JsonSerializer.Deserialize>(response.Content ?? "", Json); } - catch (JsonException ex) { parseError = ex; } + try + { + result = JsonSerializer.Deserialize>(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(Json); + } + catch (JsonException ex) + { + throw new ApiException(ApiErrorKind.InvalidResponse, "服务器响应 data 格式无效。", + response.StatusCode, result.Code, result.TraceId, ex); + } } public async Task GetRequiredAsync(string route, Method method, object? body = null, diff --git a/Source/Store.Management.Framework/Controls/ConfirmDialog.cs b/Source/Store.Management.Framework/Controls/ConfirmDialog.cs new file mode 100644 index 0000000..f7e691a --- /dev/null +++ b/Source/Store.Management.Framework/Controls/ConfirmDialog.cs @@ -0,0 +1,289 @@ +using MAX.Core.Commons; +using Prism.Commands; +using Prism.Mvvm; +using Prism.Services.Dialogs; +using System; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Input; +using System.Windows.Media; + +namespace Store.Management.Framework.Controls; + +public static class IDialogServiceExtension +{ + public static void ShowDialog(this IDialogService dialogService, string name, TParameter parameter, Action callback) where TParameter : class + { + dialogService.ShowDialog(name, new DialogParameters + { + {"parameter", parameter}, + }, callback); + } + public static void Show(this IDialogService dialogService, string name, TParameter parameter, Action callback) where TParameter : class + { + dialogService.Show(name, new DialogParameters + { + {"parameter", parameter}, + }, callback); + } +} + + +/// +/// 确定取消对话框 ViewModel +/// +public class ConfirmDialogViewModel : BindableBase, IDialogAware +{ + public ConfirmDialogViewModel() + { + + } + + private string title = ""; + private bool _enableConfirm = true; + private bool _ShowConfirm = true; + private bool _ShowCancel = true; + private bool _ShowErrorMsg = true; + private string _ErrorMsg = ""; + private string confirmText = "确定"; + private string cancelText = "取消"; + private string backText = "返回"; + private bool allowEscape = true; + private bool allowEnter = true; + private bool isLoading = false; + + public string Title + { + get => title; + protected set => SetProperty(ref title, value); + } + + public bool AllowEscape + { + get => allowEscape; + set => SetProperty(ref allowEscape, value); + } + + public bool AllowEnter + { + get => allowEnter; + set => SetProperty(ref allowEnter, value); + } + public bool IsLoading + { + get => isLoading; + set => SetProperty(ref isLoading, value, () => + { + AllowEscape = !value; + AllowEnter = !value; + EnableConfirm = !value; + }); + } + // + // + // + private SolidColorBrush _Underpaint = new SolidColorBrush(Color.FromRgb(238, 238, 238)); + /// + /// 背景底色 + /// + public SolidColorBrush Underpaint + { + get => _Underpaint; + set => SetProperty(ref _Underpaint, value); + } + + public event Action RequestClose; + protected void OnRequestClose(IDialogResult result) + { + RequestClose?.Invoke(result); + } + + public virtual bool CanCloseDialog() + { + return true; + } + + public virtual void OnDialogClosed() + { + + } + + public virtual void OnDialogOpened(IDialogParameters parameters) + { + + } + + public bool EnableConfirm + { + get => _enableConfirm; + set => SetProperty(ref _enableConfirm, value); + } + + public bool ShowConfirm + { + get => _ShowConfirm; + set => SetProperty(ref _ShowConfirm, value); + } + public bool ShowCancel + { + get => _ShowCancel; + set => SetProperty(ref _ShowCancel, value); + } + public bool ShowErrorMsg + { + get => _ShowErrorMsg; + set => SetProperty(ref _ShowErrorMsg, value); + } + + public string ConfirmText + { + get => confirmText; + set => SetProperty(ref confirmText, value); + } + public string CancelText + { + get => cancelText; + set => SetProperty(ref cancelText, value); + } + public string BackText + { + get => backText; + set => SetProperty(ref backText, value); + } + + /// + /// 错误提示 + /// + public string ErrorMsg + { + get => _ErrorMsg; + set => SetProperty(ref _ErrorMsg, value); + } + + public virtual ICommand ConfirmCommand => new DelegateCommand(_Confirm); + + protected virtual void _Confirm() + { + DialogResult ret = Confirm(); + if (ret != null) + { + RequestClose?.Invoke(ret); + } + } + + protected virtual DialogResult Confirm() + { + return new DialogResult(ButtonResult.OK); + } + + public virtual ICommand CancelCommand => new DelegateCommand(_Cancel); + + protected virtual void _Cancel() + { + DialogResult ret = Cancel(); + if (ret != null) + { + RequestClose?.Invoke(ret); + } + else + { + RequestClose?.Invoke(new DialogResult(ButtonResult.Cancel)); + } + } + protected virtual DialogResult Cancel() + { + return new DialogResult(ButtonResult.Cancel); + } + + public virtual void Loaded(object content) + { + var bb = Application.Current.TryFindResource("BackgroundBrush") as SolidColorBrush; + if (bb != null) + { + Underpaint = bb; + } + + + } + + public class AsyncConfirmDialogViewModel : ConfirmDialogViewModel + { + public override ICommand ConfirmCommand => new AsyncDelegateCommand(_Confirm); + + protected new async Task _Confirm() + { + DialogResult ret = await Confirm(); + if (ret != null) + { + OnRequestClose(ret); + } + } + + protected new virtual async Task Confirm() + { + return await Task.FromResult(new DialogResult(ButtonResult.OK)); + } + } + + public interface IDialogClassParameters + { + + } +} +/// 确定取消对话框 +/// +/// 参数Model +public class ConfirmDialogViewModel : ConfirmDialogViewModel where TParameter : class +{ + protected TParameter Parameter { get; set; } + public override void OnDialogOpened(IDialogParameters parameters) + { + base.OnDialogOpened(parameters); + if (parameters.TryGetValue("parameter", out TParameter parameter)) + { + Parameter = parameter; + } + } +} + +public abstract class ConfirmDialogViewModel : ConfirmDialogViewModel where TParameter : class where TResult : DialogResult/*, new()*/ +{ + protected TParameter Parameter { get; set; } + public override void OnDialogOpened(IDialogParameters parameters) + { + base.OnDialogOpened(parameters); + if (parameters.TryGetValue("parameter", out TParameter parameter)) + { + Parameter = parameter; + } + } + protected override void _Confirm() + { + TResult ret = Confirm(); + if (ret != null) + { + base.OnRequestClose(ret); + } + } + protected new abstract TResult Confirm(); + + protected override void _Cancel() + { + TResult ret = Cancel(); + if (ret != null) + { + base.OnRequestClose(ret); + } + else + { + base.OnRequestClose(new DialogResult(ButtonResult.Cancel)); + } + } + protected new abstract TResult Cancel(); +} + + +//public abstract class ConfirmDialogParameterViewModel : ConfirmDialogViewModel +//{ +// public abstract class Parameter; +//} \ No newline at end of file diff --git a/Source/Store.Management/App.xaml.cs b/Source/Store.Management/App.xaml.cs index 172d8e4..86dab38 100644 --- a/Source/Store.Management/App.xaml.cs +++ b/Source/Store.Management/App.xaml.cs @@ -1,13 +1,14 @@ -using Prism.Ioc; -using Prism.Services.Dialogs; using MAX.Notification; -using Store.Management.Notifications; +using Prism.Ioc; +using Prism.Mvvm; +using Prism.Services.Dialogs; +using Store.Management.Api; using Store.Management.Framework; +using Store.Management.Notifications; using Store.Management.Services; using Store.Management.ViewModels; using Store.Management.Views; using System.Windows; -using Store.Management.Api; namespace Store.Management; public partial class App : FrameworkApplication { @@ -22,51 +23,61 @@ public partial class App : FrameworkApplication BaseUrl = new Uri("http://localhost:14312/api/") }); - protected override Window CreateShell() => null!; + protected override Window CreateShell() + { + return Container.Resolve(); + } protected override void OnInitialized() { - base.OnInitialized(); - // 登录弹窗关闭与主窗口创建之间不触发 WPF 的最后窗口退出。 - ShutdownMode = ShutdownMode.OnExplicitShutdown; - Container.Resolve().ShowDialog( - nameof(LoginWindow), new DialogParameters(), result => - { - if (result.Result != ButtonResult.OK) - { - Shutdown(); - return; - } + base.OnInitialized(); + //base.OnInitialized(); + //// 登录弹窗关闭与主窗口创建之间不触发 WPF 的最后窗口退出。 + //ShutdownMode = ShutdownMode.OnExplicitShutdown; + //Container.Resolve().ShowDialog( + // nameof(LoginWindow), new DialogParameters(), result => + // { + // if (result.Result != ButtonResult.OK) + // { + // Shutdown(); + // return; + // } - MainWindow = Container.Resolve(); - ShutdownMode = ShutdownMode.OnMainWindowClose; - MainWindow.Show(); - }); - } + // MainWindow = Container.Resolve(); + // ShutdownMode = ShutdownMode.OnMainWindowClose; + // MainWindow.Show(); + // }); + } - protected override void RegisterTypes(IContainerRegistry registry) + protected override void RegisterTypes(IContainerRegistry containerRegistry) { - registry.RegisterInstance(CreateApiClient()); - registry.Register(provider => new LoginViewModel(provider.Resolve().Auth)); - registry.RegisterSingleton(); - registry.Register(); - registry.RegisterSingleton(); - registry.Register(provider => new NotificationPublisher( - new PrismNotificationServiceProvider(provider))); - // 显式提供处理器集合,展示一条通知分发给多个业务模块。 - registry.Register>>(provider => - new INotificationHandler[] - { - new ProductCacheHandler(provider.Resolve()), - new ProductAuditHandler(provider.Resolve()) - }); - registry.RegisterDialog(); - registry.RegisterDialog(); + + containerRegistry.RegisterDialogWindow(); + + ViewModelLocationProvider.Register(); + + + //registry.RegisterInstance(CreateApiClient()); + // registry.Register(provider => new LoginViewModel(provider.Resolve().Auth)); + // registry.RegisterSingleton(); + // registry.Register(); + // registry.RegisterSingleton(); + // registry.Register(provider => new NotificationPublisher( + // new PrismNotificationServiceProvider(provider))); + // // 显式提供处理器集合,展示一条通知分发给多个业务模块。 + // registry.Register>>(provider => + // new INotificationHandler[] + // { + // new ProductCacheHandler(provider.Resolve()), + // new ProductAuditHandler(provider.Resolve()) + // }); + // registry.RegisterDialog(); + // registry.RegisterDialog(); } protected override void OnExit(ExitEventArgs e) { - try { Container?.Resolve().Dispose(); } - finally { base.OnExit(e); } + //try { Container?.Resolve().Dispose(); } + //finally { base.OnExit(e); } } } diff --git a/Source/Store.Management/MainWindow.xaml b/Source/Store.Management/MainWindow.xaml index e900502..41245a4 100644 --- a/Source/Store.Management/MainWindow.xaml +++ b/Source/Store.Management/MainWindow.xaml @@ -1,13 +1,14 @@ - - \ No newline at end of file + \ No newline at end of file diff --git a/Source/Store.Management/MainWindow.xaml.cs b/Source/Store.Management/MainWindow.xaml.cs index 028f5a1..b345b2a 100644 --- a/Source/Store.Management/MainWindow.xaml.cs +++ b/Source/Store.Management/MainWindow.xaml.cs @@ -1,6 +1,19 @@ +using Prism.Mvvm; +using Store.Management.Framework.Controls; using System.Windows; namespace Store.Management; -public partial class MainWindow : Window + +public partial class MainWindow : WindowBase { - public MainWindow() => InitializeComponent(); + public MainWindow() => InitializeComponent(); } + +public class MainWindowViewModel : BindableBase +{ + private string _title="TTTT"; + public string Title + { + get => _title; + set => SetProperty(ref _title, value); + } +} \ No newline at end of file diff --git a/Source/Store.Management/Store.Management.csproj b/Source/Store.Management/Store.Management.csproj index 496f6df..ac7173e 100644 --- a/Source/Store.Management/Store.Management.csproj +++ b/Source/Store.Management/Store.Management.csproj @@ -2,7 +2,6 @@ WinExe - enable enable true diff --git a/Tests/Store.Management.Api.SmokeTests/Program.cs b/Tests/Store.Management.Api.SmokeTests/Program.cs index f4bc2a5..99ed442 100644 --- a/Tests/Store.Management.Api.SmokeTests/Program.cs +++ b/Tests/Store.Management.Api.SmokeTests/Program.cs @@ -59,7 +59,93 @@ using var cancelled = new CancellationTokenSource(); cancelled.Cancel(); try { await api.Auth.LoginAsync(new LoginRequest(), cancelled.Token); throw new Exception("取消未生效"); } catch (OperationCanceledException) { } -Console.WriteLine("PASS: 登录、路由、Bearer、分页、校验、业务错误、格式错误、403、HTTP/业务401、退出、取消。"); +// 错误信封的 data 不必符合成功 DTO,仍须保留错误类型与追踪信息。 +handler.Json = """{"code":200,"data":{"token":"session-four"}}"""; +await api.Auth.LoginAsync(new LoginRequest { UserName = "admin", Password = "secret" }); +handler.Json = """{"code":400,"msg":"账号错误","traceId":"error-data","data":[]} """; +try +{ + await api.Auth.LoginAsync(new LoginRequest { UserName = "admin", Password = "wrong" }); + throw new Exception("错误登录应失败"); +} +catch (ApiException ex) +{ + Check(ex.Kind == ApiErrorKind.Business && ex.Code == 400 && ex.TraceId == "error-data" + && ex.Message == "账号错误", "错误 data 不应掩盖业务错误"); +} +Check(api.Session.IsAuthenticated, "失败登录不应替换原会话"); +handler.Json = """{"code":200,"data":{"token":"","tokenType":"Bearer"}}"""; +await Error(async () => await api.Auth.LoginAsync(new LoginRequest { UserName = "admin", Password = "secret" }), ApiErrorKind.InvalidResponse); +Check(api.Session.IsAuthenticated, "无效 Token 不应替换原会话"); +handler.Json = """{"code":200,"traceId":"bad-data","data":[]} """; +await Error(async () => await api.Auth.MeAsync(), ApiErrorKind.InvalidResponse); +handler.Json = """{"code":200,"data":null}"""; +await Error(async () => await api.Auth.MeAsync(), ApiErrorKind.InvalidResponse); +handler.Json = """{"code":401,"traceId":"expired","data":[]} """; +await Error(async () => await api.Auth.MeAsync(), ApiErrorKind.Unauthorized); +Check(!api.Session.IsAuthenticated, "data 类型不匹配时业务 401 仍应清理会话"); + +handler.Json = """{"code":200,"data":{"token":"old-token"}}"""; +await api.Auth.LoginAsync(new LoginRequest { UserName = "admin", Password = "secret" }); +TaskCompletionSource arrived = new(TaskCreationOptions.RunContinuationsAsynchronously); +TaskCompletionSource release = new(TaskCreationOptions.RunContinuationsAsynchronously); +handler.BeforeResponse = async cancellationToken => +{ + arrived.SetResult(); + await release.Task.WaitAsync(cancellationToken); +}; +handler.Json = """{"code":401,"data":[]} """; +Task oldRequest = api.Auth.MeAsync(); +await arrived.Task.WaitAsync(TimeSpan.FromSeconds(5)); +handler.BeforeResponse = null; +handler.Json = """{"code":200,"data":{"token":"new-token"}}"""; +await api.Auth.LoginAsync(new LoginRequest { UserName = "admin", Password = "secret" }); +release.SetResult(); +await Error(async () => await oldRequest, ApiErrorKind.Unauthorized); +Check(api.Session.IsAuthenticated, "旧请求 401 不得清理新会话"); +handler.Json = """{"code":200,"data":{"userName":"admin"}}"""; +await api.Auth.MeAsync(); +Check(handler.Authorization == "Bearer new-token", "重新登录应使用新 Token"); + +arrived = new(TaskCreationOptions.RunContinuationsAsynchronously); +release = new(TaskCreationOptions.RunContinuationsAsynchronously); +handler.BeforeResponse = async cancellationToken => +{ + arrived.SetResult(); + await release.Task.WaitAsync(cancellationToken); +}; +handler.Json = """{"code":200,"data":{"token":"last-token"}}"""; +Task pendingLogin = api.Auth.LoginAsync(new LoginRequest { UserName = "admin", Password = "secret" }); +await arrived.Task.WaitAsync(TimeSpan.FromSeconds(5)); +int beforeLogout = handler.Count; +Task pendingLogout = api.Auth.LogoutAsync(); +Check(handler.Count == beforeLogout && !pendingLogout.IsCompleted, "退出必须等待登录完成"); +handler.BeforeResponse = null; +handler.Json = """{"code":200,"data":null}"""; +release.SetResult(); +await Task.WhenAll(pendingLogin, pendingLogout).WaitAsync(TimeSpan.FromSeconds(5)); +Check(!api.Session.IsAuthenticated && handler.Authorization == "Bearer last-token", "退出应撤销刚完成的登录"); + +handler.Json = """{"code":200,"data":{"token":"logout-token"}}"""; +await api.Auth.LoginAsync(new LoginRequest { UserName = "admin", Password = "secret" }); +handler.Status = HttpStatusCode.InternalServerError; +handler.Json = "server-error"; +await Error(() => api.Auth.LogoutAsync(), ApiErrorKind.Http); +Check(!api.Session.IsAuthenticated, "退出失败也必须清理本地会话"); +handler.Status = HttpStatusCode.OK; +handler.BeforeResponse = cancellationToken => Task.Delay(Timeout.Infinite, cancellationToken); +using CancellationTokenSource inFlight = new(TimeSpan.FromMilliseconds(100)); +try +{ + await api.Auth.LoginAsync(new LoginRequest { UserName = "admin", Password = "secret" }, inFlight.Token); + throw new Exception("请求中取消未生效"); +} +catch (OperationCanceledException) +{ + Check(!api.Session.IsAuthenticated, "取消登录不得保存会话"); +} +handler.BeforeResponse = null; +Console.WriteLine("PASS: 登录、路由、Bearer、分页、校验、错误分类、无效 Token/data、401 会话隔离、登录退出串行、退出失败清理、取消。"); sealed class StubHandler : HttpMessageHandler { @@ -69,12 +155,19 @@ sealed class StubHandler : HttpMessageHandler public string Path { get; private set; } = ""; public string? Authorization { get; private set; } public string? Body { get; private set; } + public Func? BeforeResponse { get; set; } protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { Count++; Path = request.RequestUri!.AbsolutePath; Authorization = request.Headers.Authorization?.ToString(); Body = request.Content is null ? null : await request.Content.ReadAsStringAsync(cancellationToken); - return new HttpResponseMessage(Status) { Content = new StringContent(Json, Encoding.UTF8, "application/json") }; + HttpStatusCode status = Status; + string json = Json; + if (BeforeResponse is not null) + { + await BeforeResponse(cancellationToken); + } + return new HttpResponseMessage(status) { Content = new StringContent(json, Encoding.UTF8, "application/json") }; } } diff --git a/Tests/Store.Management.Api.SmokeTests/Store.Management.Api.SmokeTests.csproj b/Tests/Store.Management.Api.SmokeTests/Store.Management.Api.SmokeTests.csproj index 69322f9..43c6df0 100644 --- a/Tests/Store.Management.Api.SmokeTests/Store.Management.Api.SmokeTests.csproj +++ b/Tests/Store.Management.Api.SmokeTests/Store.Management.Api.SmokeTests.csproj @@ -1,4 +1,4 @@ - Exenet10.0enableenable + Exenet10.0-windowsenableenable