NextUI 用户身份与认证设计方案
一、问题分析
当前状态
IUserPreferences- 存储在浏览器 localStorage/Cookie,无用户概念IUserState- 简单的用户状态,无认证机制- 所有数据都是客户端本地存储,无服务端持久化
目标场景
- 单应用认证 - 一个应用使用 OpenID Connect 登录
- 多应用 SSO - 多个 NextUI 应用共享同一用户身份
- 混合用户类型 - 内部员工 + 外部客户使用同一系统
核心决策
| 决策点 | 建议 | 理由 |
|---|---|---|
| 放在哪个包? | NextUI.Identity (新包) |
不与 UI 混合,可独立升级 |
| 做认证服务? | 不做 | 复杂度高,已有成熟方案 |
| 做什么? | 客户端集成层 | 对接 OIDC,管理用户状态 |
二、架构设计
整体架构
┌─────────────────────────────────────────────────────────────────┐
│ NextUI 应用 │
├─────────────────────────────────────────────────────────────────┤
│ NextUI.Blazor (UI) │ NextUI.App (应用层) │
│ ├── SxLoginButton │ ├── App-specific services │
│ ├── SxUserMenu │ └── ... │
│ └── SxAuthGuard │ │
├──────────────────────────────────┼──────────────────────────────┤
│ NextUI.Identity (新包) │
│ ├── IIdentityService 用户身份服务 │
│ ├── IUserProfileService 用户资料服务 │
│ ├── IUserPreferencesSync 偏好同步服务 │
│ ├── OpenIdConnectHandler OIDC 处理器 │
│ └── UserContext 用户上下文 │
├─────────────────────────────────────────────────────────────────┤
│ NextUI.Core (核心接口) │
│ ├── ICurrentUser 当前用户抽象 │
│ ├── IUserPreferences 用户偏好 (已有) │
│ └── IUserState 用户状态 (已有) │
├─────────────────────────────────────────────────────────────────┤
│ 外部认证服务 │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Keycloak │ │ Auth0 │ │ Azure AD │ │ 自建 │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
└─────────────────────────────────────────────────────────────────┘
包职责划分
NextUI.Core - 接口定义,无外部依赖
NextUI.Identity - 身份认证集成,OIDC 客户端 ← 新包
NextUI.Identity.EF - Entity Framework 持久化 ← 可选包
NextUI.Blazor - 身份相关 UI 组件
三、核心接口设计
3.1 用户身份抽象 (NextUI.Core)
namespace NextUI.Core;
/// <summary>
/// 当前用户抽象 - 所有应用统一使用此接口
/// </summary>
public interface ICurrentUser
{
/// <summary>是否已认证</summary>
bool IsAuthenticated { get; }
/// <summary>用户唯一标识 (来自 IdP 的 sub claim)</summary>
string? UserId { get; }
/// <summary>显示名称</summary>
string? DisplayName { get; }
/// <summary>邮箱</summary>
string? Email { get; }
/// <summary>头像 URL</summary>
string? AvatarUrl { get; }
/// <summary>用户角色</summary>
IReadOnlyList<string> Roles { get; }
/// <summary>用户声明</summary>
IReadOnlyDictionary<string, string> Claims { get; }
/// <summary>检查是否有指定角色</summary>
bool IsInRole(string role);
/// <summary>检查是否有指定权限</summary>
bool HasPermission(string permission);
/// <summary>用户状态变更事件</summary>
event Action? Changed;
}
/// <summary>
/// 用户资料 - 应用层面的扩展信息
/// </summary>
public interface IUserProfile
{
string UserId { get; }
string? Nickname { get; set; }
string? Bio { get; set; }
string? PhoneNumber { get; set; }
string? Timezone { get; set; }
string? Locale { get; set; }
Dictionary<string, object?> Metadata { get; }
DateTimeOffset CreatedAt { get; }
DateTimeOffset UpdatedAt { get; }
}
3.2 身份服务 (NextUI.Identity)
namespace NextUI.Identity;
/// <summary>
/// 身份认证服务
/// </summary>
public interface IIdentityService
{
/// <summary>当前用户</summary>
ICurrentUser CurrentUser { get; }
/// <summary>发起登录流程</summary>
/// <param name="returnUrl">登录后返回地址</param>
/// <param name="provider">指定认证提供者 (可选)</param>
Task LoginAsync(string? returnUrl = null, string? provider = null);
/// <summary>发起注销流程</summary>
Task LogoutAsync(string? returnUrl = null);
/// <summary>静默刷新 Token</summary>
Task<bool> RefreshTokenAsync();
/// <summary>获取访问令牌 (用于调用 API)</summary>
Task<string?> GetAccessTokenAsync();
/// <summary>用户登录事件</summary>
event Action<ICurrentUser>? UserLoggedIn;
/// <summary>用户注销事件</summary>
event Action? UserLoggedOut;
}
/// <summary>
/// 用户资料服务
/// </summary>
public interface IUserProfileService
{
/// <summary>获取用户资料</summary>
Task<IUserProfile?> GetProfileAsync(string userId);
/// <summary>获取当前用户资料</summary>
Task<IUserProfile?> GetCurrentProfileAsync();
/// <summary>更新用户资料</summary>
Task UpdateProfileAsync(IUserProfile profile);
/// <summary>用户资料变更事件</summary>
event Action<IUserProfile>? ProfileChanged;
}
/// <summary>
/// 用户偏好同步服务 - 将本地偏好同步到服务端
/// </summary>
public interface IUserPreferencesSync
{
/// <summary>同步本地偏好到服务端</summary>
Task SyncToServerAsync();
/// <summary>从服务端加载偏好到本地</summary>
Task SyncFromServerAsync();
/// <summary>启用自动同步</summary>
bool AutoSyncEnabled { get; set; }
}
3.3 配置选项
namespace NextUI.Identity;
/// <summary>
/// 身份认证配置
/// </summary>
public class IdentityOptions
{
/// <summary>认证模式</summary>
public AuthenticationMode Mode { get; set; } = AuthenticationMode.OpenIdConnect;
/// <summary>OIDC 配置</summary>
public OpenIdConnectOptions? OpenIdConnect { get; set; }
/// <summary>用户资料存储方式</summary>
public ProfileStorageMode ProfileStorage { get; set; } = ProfileStorageMode.LocalOnly;
/// <summary>用户资料 API 端点 (当 ProfileStorage = RemoteApi 时)</summary>
public string? ProfileApiEndpoint { get; set; }
/// <summary>偏好自动同步</summary>
public bool AutoSyncPreferences { get; set; } = true;
/// <summary>多租户配置</summary>
public MultiTenantOptions? MultiTenant { get; set; }
/// <summary>离线支持配置</summary>
public OfflineOptions Offline { get; set; } = new();
/// <summary>Token 存储配置</summary>
public TokenStorageOptions TokenStorage { get; set; } = new();
/// <summary>权限模型配置</summary>
public AuthorizationOptions Authorization { get; set; } = new();
}
public class OpenIdConnectOptions
{
/// <summary>Authority URL (如 https://login.example.com)</summary>
public string Authority { get; set; } = "";
/// <summary>Client ID</summary>
public string ClientId { get; set; } = "";
/// <summary>Client Secret (机密客户端时需要)</summary>
public string? ClientSecret { get; set; }
/// <summary>Scopes</summary>
public List<string> Scopes { get; set; } = new() { "openid", "profile", "email" };
/// <summary>Response Type</summary>
public string ResponseType { get; set; } = "code";
/// <summary>登录后回调路径</summary>
public string CallbackPath { get; set; } = "/signin-oidc";
/// <summary>注销后回调路径</summary>
public string SignedOutCallbackPath { get; set; } = "/signout-callback-oidc";
}
public enum AuthenticationMode
{
/// <summary>无认证 (匿名模式)</summary>
None,
/// <summary>OpenID Connect</summary>
OpenIdConnect,
/// <summary>自定义 (由应用实现)</summary>
Custom
}
public enum ProfileStorageMode
{
/// <summary>仅本地存储 (localStorage)</summary>
LocalOnly,
/// <summary>远程 API</summary>
RemoteApi,
/// <summary>本地 + 远程同步</summary>
Hybrid
}
四、多应用 SSO 方案
场景分析
┌─────────────────────────────────────────────────────────────────┐
│ Identity Provider │
│ (Keycloak / Auth0 / Azure AD) │
│ │
│ Realm: my-company │
│ ├── Client: cms-app (内容管理系统) │
│ ├── Client: crm-app (客户关系管理) │
│ ├── Client: chat-app (即时通信) │
│ └── Client: portal-app (用户门户) │
│ │
│ Users: │
│ ├── Internal Users (员工) - Role: employee │
│ └── External Users (客户) - Role: customer │
└─────────────────────────────────────────────────────────────────┘
│
┌──────────────────┼──────────────────┐
│ │ │
▼ ▼ ▼
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ CMS App │ │ CRM App │ │ Chat App │
│ (NextUI) │ │ (NextUI) │ │ (NextUI) │
└─────────────┘ └─────────────┘ └─────────────┘
│ │ │
└──────────────────┼──────────────────┘
│
▼
┌─────────────────┐
│ Shared User │
│ Profile Store │
│ (Optional) │
└─────────────────┘
SSO 配置方式
// 每个应用的 Program.cs
builder.Services.AddNextUIIdentity(options =>
{
options.Mode = AuthenticationMode.OpenIdConnect;
options.OpenIdConnect = new OpenIdConnectOptions
{
Authority = "https://auth.my-company.com/realms/my-company",
ClientId = "cms-app", // 每个应用不同的 Client ID
Scopes = { "openid", "profile", "email", "roles" }
};
// 可选:共享用户资料服务
options.ProfileStorage = ProfileStorageMode.RemoteApi;
options.ProfileApiEndpoint = "https://api.my-company.com/user-profiles";
});
用户类型区分
// 通过 Claims/Roles 区分用户类型
public static class UserTypes
{
public const string Internal = "internal"; // 内部员工
public const string External = "external"; // 外部客户
public const string Partner = "partner"; // 合作伙伴
}
// 使用示例
@inject ICurrentUser User
@if (User.IsInRole("employee"))
{
<AdminMenu />
}
else if (User.IsInRole("customer"))
{
<CustomerMenu />
}
五、实现路线图
Phase 1: 基础框架 (MVP)
| 任务 | 包 | 描述 |
|---|---|---|
| 1.1 | NextUI.Core | 定义 ICurrentUser 接口 |
| 1.2 | NextUI.Identity | 创建新包,实现 OIDC 客户端 |
| 1.3 | NextUI.Identity | 实现 IIdentityService |
| 1.4 | NextUI.Blazor | 添加 <AuthorizeView> 集成 |
MVP 目标:用户可以通过 OIDC 登录/注销,获取基本信息。
Phase 2: 用户资料
| 任务 | 包 | 描述 |
|---|---|---|
| 2.1 | NextUI.Identity | 实现 IUserProfileService |
| 2.2 | NextUI.Identity | 本地资料存储 |
| 2.3 | NextUI.Identity | 远程 API 集成 |
| 2.4 | NextUI.Identity | 偏好同步 (IUserPreferencesSync) |
Phase 3: UI 组件
| 任务 | 包 | 描述 |
|---|---|---|
| 3.1 | NextUI.Blazor | SxLoginButton - 登录按钮 |
| 3.2 | NextUI.Blazor | SxUserAvatar - 用户头像 |
| 3.3 | NextUI.Blazor | SxUserMenu - 用户下拉菜单 |
| 3.4 | NextUI.Blazor | SxAuthGuard - 路由权限守卫 |
Phase 4: 多租户
| 任务 | 包 | 描述 |
|---|---|---|
| 4.1 | NextUI.Identity | ITenant, ITenantMembership 接口 |
| 4.2 | NextUI.Identity | ITenantContext - 多租户上下文 |
| 4.3 | NextUI.Identity | ITenantService - 租户管理服务 |
| 4.4 | NextUI.Blazor | SxTenantSwitcher - 租户切换组件 |
Phase 5: 管理组件
| 任务 | 包 | 描述 |
|---|---|---|
| 5.1 | NextUI.Identity.Admin | SxUserList, SxUserEditor |
| 5.2 | NextUI.Identity.Admin | SxTenantList, SxTenantEditor |
| 5.3 | NextUI.Identity.Admin | SxMemberList - 成员管理 |
| 5.4 | NextUI.Identity.Admin | 预组装管理页面 |
| 5.5 | NextUI.Identity.Admin | 自动路由注册 |
Phase 6: 持久化与高级功能 (可选)
| 任务 | 包 | 描述 |
|---|---|---|
| 6.1 | NextUI.Identity.EF | Entity Framework 持久化 |
| 6.2 | NextUI.Identity | 审计日志集成 |
| 6.3 | NextUI.Identity | 权限细粒度控制 (ABAC) |
六、使用示例
6.1 基础用法 (开箱即用)
// Program.cs
builder.Services.AddNextUIIdentity(options =>
{
options.OpenIdConnect = new()
{
Authority = "https://login.example.com",
ClientId = "my-app"
};
});
// 使用
builder.Services.AddNextDesignSystem();
@* App.razor *@
<Router AppAssembly="@typeof(App).Assembly">
<Found Context="routeData">
<AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)">
<NotAuthorized>
<RedirectToLogin />
</NotAuthorized>
</AuthorizeRouteView>
</Found>
</Router>
@* MainLayout.razor *@
<SxAppShell>
<NavBar>
<SxNavBar>
<UserMenu>
<SxUserMenu /> @* 自动显示登录/用户信息 *@
</UserMenu>
</SxNavBar>
</NavBar>
<Body>
@Body
</Body>
</SxAppShell>
6.2 自定义用法
@inject IIdentityService Identity
@inject ICurrentUser User
<div>
@if (User.IsAuthenticated)
{
<span>欢迎, @User.DisplayName!</span>
<button @onclick="() => Identity.LogoutAsync()">注销</button>
}
else
{
<button @onclick="() => Identity.LoginAsync()">登录</button>
}
</div>
6.3 权限控制
@* 基于角色 *@
<AuthorizeView Roles="admin,manager">
<Authorized>
<AdminPanel />
</Authorized>
<NotAuthorized>
<p>您没有权限访问此内容</p>
</NotAuthorized>
</AuthorizeView>
@* 基于策略 *@
<AuthorizeView Policy="CanEditContent">
<EditButton />
</AuthorizeView>
七、与现有方案的兼容性
场景:已有 ASP.NET Core Identity
// 应用已经有自己的认证
builder.Services.AddAuthentication()
.AddCookie()
.AddOpenIdConnect();
// NextUI 可以直接使用,不冲突
builder.Services.AddNextUIIdentity(options =>
{
options.Mode = AuthenticationMode.Custom; // 不启用 NextUI 的 OIDC
});
// 只使用 NextUI 的用户资料服务
builder.Services.AddScoped<ICurrentUser, AspNetCoreCurrentUserAdapter>();
场景:纯前端应用 (无服务端)
builder.Services.AddNextUIIdentity(options =>
{
options.Mode = AuthenticationMode.OpenIdConnect;
options.ProfileStorage = ProfileStorageMode.LocalOnly; // 纯本地存储
});
八、技术选型
| 方面 | 选择 | 理由 |
|---|---|---|
| OIDC 库 | Microsoft.AspNetCore.Authentication.OpenIdConnect |
官方支持,稳定 |
| Token 存储 | ProtectedBrowserStorage |
安全,Blazor 原生 |
| 状态管理 | AuthenticationStateProvider |
Blazor 标准机制 |
| API 客户端 | HttpClient + IHttpClientFactory |
标准,可配置 |
九、安全考虑
- Token 存储:使用
ProtectedBrowserStorage,不存明文 - CSRF 防护:OIDC 流程自带 state 参数
- XSS 防护:不在 localStorage 存储敏感 token
- Token 刷新:静默刷新,用户无感知
- Logout:同时清理 IdP session 和本地 session
十、职责边界
NextUI.Identity vs 身份提供者 (IdP)
明确划分职责,避免重复建设:
┌─────────────────────────────────────────────────────────────────────┐
│ 身份提供者 (IdP) 职责 │
│ Keycloak / Auth0 / Azure AD / IdentityServer │
├─────────────────────────────────────────────────────────────────────┤
│ ✓ 用户注册/登录认证 │
│ ✓ 密码管理、重置、MFA │
│ ✓ 用户存储 (账号、密码、基本信息) │
│ ✓ 角色/组管理 │
│ ✓ Token 签发 (ID Token, Access Token, Refresh Token) │
│ ✓ SSO 会话管理 │
│ ✓ 社交登录 (Google, GitHub, etc.) │
│ ✓ 用户联邦 (LDAP, Active Directory) │
│ ✓ 审计日志 │
└─────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ NextUI.Identity 职责 │
├─────────────────────────────────────────────────────────────────────┤
│ ✓ OIDC 客户端集成 (登录/注销流程) │
│ ✓ Token 管理 (存储、刷新、附加到 API 请求) │
│ ✓ 当前用户状态 (ICurrentUser) │
│ ✓ Blazor AuthenticationStateProvider 集成 │
│ ✓ 应用级用户资料扩展 (IUserProfileService) │
│ ✓ 用户偏好同步 (主题、语言、布局等) │
│ ✓ 权限检查辅助 (IsInRole, HasPermission) │
│ ✓ 身份相关 UI 组件 │
│ ✓ 多租户上下文管理 │
└─────────────────────────────────────────────────────────────────────┘
我们不做什么
| 功能 | 说明 | 建议 |
|---|---|---|
| 用户注册 | 复杂度高,各 IdP 都有完善方案 | 使用 IdP 内置页面或 API |
| 密码管理 | 安全敏感,不应重复实现 | 使用 IdP 内置功能 |
| MFA | 需要专业安全知识 | 使用 IdP 内置功能 |
| 用户存储 | IdP 核心功能 | 使用 IdP 数据库 |
| 审计日志 | IdP 有详细记录 | 查询 IdP 审计 API |
我们扩展什么
| 功能 | 说明 | 存储位置 |
|---|---|---|
| 用户偏好 | 主题、语言、布局设置 | 应用数据库 / localStorage |
| 应用资料 | 昵称、签名、个人主页设置 | 应用数据库 |
| 租户关联 | 用户所属组织、角色映射 | 应用数据库 |
| 通知设置 | 邮件/推送通知偏好 | 应用数据库 |
| 最近访问 | 最近打开的项目、文档 | 应用数据库 |
十一、多租户设计
概念模型
┌─────────────────────────────────────────────────────────────────────┐
│ 组织 (Tenant) │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Acme Inc │ │ Tech Corp │ │ Startup X │ │
│ │ tenant_001 │ │ tenant_002 │ │ tenant_003 │ │
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ 用户-租户关联表 │ │
│ │ ┌─────────┬───────────┬──────────┬─────────────────────┐ │ │
│ │ │ user_id │ tenant_id │ role │ permissions │ │ │
│ │ ├─────────┼───────────┼──────────┼─────────────────────┤ │ │
│ │ │ alice │ tenant_001│ owner │ ["*"] │ │ │
│ │ │ alice │ tenant_002│ member │ ["read", "write"] │ │ │
│ │ │ bob │ tenant_001│ admin │ ["read","write","admin"]│ │ │
│ │ │ bob │ tenant_003│ viewer │ ["read"] │ │ │
│ │ └─────────┴───────────┴──────────┴─────────────────────┘ │ │
│ └────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
核心接口
namespace NextUI.Identity;
/// <summary>
/// 租户信息
/// </summary>
public interface ITenant
{
/// <summary>租户唯一标识</summary>
string TenantId { get; }
/// <summary>租户名称</summary>
string Name { get; }
/// <summary>租户显示名称</summary>
string? DisplayName { get; }
/// <summary>租户 Logo</summary>
string? LogoUrl { get; }
/// <summary>租户元数据</summary>
IReadOnlyDictionary<string, object?> Metadata { get; }
}
/// <summary>
/// 用户在租户中的成员身份
/// </summary>
public interface ITenantMembership
{
/// <summary>租户信息</summary>
ITenant Tenant { get; }
/// <summary>用户在此租户中的角色</summary>
IReadOnlyList<string> Roles { get; }
/// <summary>用户在此租户中的权限</summary>
IReadOnlyList<string> Permissions { get; }
/// <summary>是否为此租户的所有者</summary>
bool IsOwner { get; }
/// <summary>加入时间</summary>
DateTimeOffset JoinedAt { get; }
}
/// <summary>
/// 多租户上下文
/// </summary>
public interface ITenantContext
{
/// <summary>当前租户 (用户选择的活动租户)</summary>
ITenant? CurrentTenant { get; }
/// <summary>用户所属的所有租户</summary>
IReadOnlyList<ITenantMembership> Memberships { get; }
/// <summary>切换当前租户</summary>
Task SwitchTenantAsync(string tenantId);
/// <summary>获取用户在当前租户的角色</summary>
IReadOnlyList<string> GetCurrentRoles();
/// <summary>检查当前租户权限</summary>
bool HasPermission(string permission);
/// <summary>租户切换事件</summary>
event Action<ITenant?>? TenantChanged;
}
/// <summary>
/// 租户管理服务 (管理员使用)
/// </summary>
public interface ITenantService
{
/// <summary>创建租户</summary>
Task<ITenant> CreateTenantAsync(string name, string? displayName = null);
/// <summary>获取租户</summary>
Task<ITenant?> GetTenantAsync(string tenantId);
/// <summary>更新租户</summary>
Task UpdateTenantAsync(ITenant tenant);
/// <summary>删除租户</summary>
Task DeleteTenantAsync(string tenantId);
/// <summary>添加用户到租户</summary>
Task AddMemberAsync(string tenantId, string userId, IEnumerable<string> roles);
/// <summary>移除用户</summary>
Task RemoveMemberAsync(string tenantId, string userId);
/// <summary>更新用户角色</summary>
Task UpdateMemberRolesAsync(string tenantId, string userId, IEnumerable<string> roles);
/// <summary>获取租户成员</summary>
Task<IReadOnlyList<TenantMember>> GetMembersAsync(string tenantId);
}
public class TenantMember
{
public string UserId { get; set; } = "";
public string? DisplayName { get; set; }
public string? Email { get; set; }
public IReadOnlyList<string> Roles { get; set; } = [];
public DateTimeOffset JoinedAt { get; set; }
}
使用示例
@inject ITenantContext TenantContext
@inject ICurrentUser User
@* 租户切换器 *@
<SxSelect @bind-Value="@_selectedTenant"
TValue="string"
Label="当前组织"
OnChange="OnTenantChange">
@foreach (var membership in TenantContext.Memberships)
{
<SxSelectItem Value="@membership.Tenant.TenantId">
@membership.Tenant.DisplayName
@if (membership.IsOwner)
{
<SxBadge>Owner</SxBadge>
}
</SxSelectItem>
}
</SxSelect>
@* 基于租户角色的权限控制 *@
@if (TenantContext.HasPermission("manage_members"))
{
<SxButton OnClick="OpenMemberManagement">管理成员</SxButton>
}
@code {
private string? _selectedTenant;
protected override void OnInitialized()
{
_selectedTenant = TenantContext.CurrentTenant?.TenantId;
}
private async Task OnTenantChange(string tenantId)
{
await TenantContext.SwitchTenantAsync(tenantId);
}
}
配置选项
public class MultiTenantOptions
{
/// <summary>是否启用多租户</summary>
public bool Enabled { get; set; } = false;
/// <summary>租户解析策略</summary>
public TenantResolutionStrategy ResolutionStrategy { get; set; }
= TenantResolutionStrategy.UserSelection;
/// <summary>租户 API 端点</summary>
public string? TenantApiEndpoint { get; set; }
/// <summary>默认租户 (首次登录时)</summary>
public string? DefaultTenantId { get; set; }
/// <summary>租户切换后是否刷新页面</summary>
public bool ReloadOnSwitch { get; set; } = false;
}
public enum TenantResolutionStrategy
{
/// <summary>用户手动选择</summary>
UserSelection,
/// <summary>从 URL 子域名解析 (如 acme.app.com)</summary>
Subdomain,
/// <summary>从 URL 路径解析 (如 /tenant/acme/...)</summary>
PathSegment,
/// <summary>从 HTTP Header 解析</summary>
Header,
/// <summary>从 Cookie 解析</summary>
Cookie
}
十二、后台管理策略
方案对比
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| A: 组件库 | 灵活定制,可深度集成到应用 | 需要自己组装页面 | 有定制需求的应用 |
| B: 独立模块 | 开箱即用,快速集成 | 定制性受限 | 快速开发场景 |
| C: 混合方案 | 平衡灵活性和便利性 | 复杂度略高 | 推荐方案 |
推荐:混合方案 (C)
┌─────────────────────────────────────────────────────────────────────┐
│ NextUI.Identity.Admin │
├─────────────────────────────────────────────────────────────────────┤
│ Layer 1: 管理组件 (可独立使用) │
│ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ │
│ │ SxUserList │ │ SxUserEditor │ │ SxRoleEditor │ │
│ │ 用户列表组件 │ │ 用户编辑表单 │ │ 角色编辑表单 │ │
│ └────────────────┘ └────────────────┘ └────────────────┘ │
│ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ │
│ │ SxTenantList │ │ SxTenantEditor │ │ SxMemberList │ │
│ │ 租户列表组件 │ │ 租户编辑表单 │ │ 成员管理组件 │ │
│ └────────────────┘ └────────────────┘ └────────────────┘ │
├─────────────────────────────────────────────────────────────────────┤
│ Layer 2: 预组装页面 (可选使用) │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ AdminUserManagementPage - 完整的用户管理页面 │ │
│ │ AdminTenantManagementPage - 完整的租户管理页面 │ │
│ │ AdminRoleManagementPage - 完整的角色管理页面 │ │
│ │ AdminAuditLogPage - 审计日志查看页面 │ │
│ └────────────────────────────────────────────────────────────┘ │
├─────────────────────────────────────────────────────────────────────┤
│ Layer 3: 路由注册 (可选) │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ builder.Services.AddNextUIAdminRoutes("/admin"); │ │
│ │ │ │
│ │ 自动注册: │ │
│ │ /admin/users → AdminUserManagementPage │ │
│ │ /admin/tenants → AdminTenantManagementPage │ │
│ │ /admin/roles → AdminRoleManagementPage │ │
│ │ /admin/audit-logs → AdminAuditLogPage │ │
│ └────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
使用方式
方式 1: 只用组件 (最大灵活性)
@* 自定义页面,使用管理组件 *@
@page "/my-admin/users"
@attribute [Authorize(Roles = "admin")]
<h1>我的用户管理</h1>
<SxUserList @bind-SelectedUser="@_selectedUser" />
@if (_selectedUser != null)
{
<SxUserEditor User="@_selectedUser"
OnSave="HandleSave"
ShowRoleEditor="true"
ShowTenantSelector="true" />
}
方式 2: 使用预组装页面
@* 直接使用预组装页面,传入配置 *@
@page "/admin/users"
@attribute [Authorize(Roles = "admin")]
<AdminUserManagementPage AllowCreate="true"
AllowDelete="true"
ShowTenantColumn="true" />
方式 3: 自动路由注册 (最快集成)
// Program.cs
builder.Services.AddNextUIIdentity(options => { ... });
builder.Services.AddNextUIAdminRoutes("/admin", options =>
{
options.RequireRole = "admin";
options.EnableUserManagement = true;
options.EnableTenantManagement = true;
options.EnableAuditLog = true;
});
管理后台部署方式
管理组件支持多种部署方式,根据你的需求选择:
┌─────────────────────────────────────────────────────────────────────┐
│ 部署方式对比 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ 方式 A: 集成到业务应用中 │
│ ──────────────────────── │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ 你的业务应用 │ │
│ │ ┌─────────────────┐ ┌─────────────────┐ │ │
│ │ │ 业务页面 │ │ 管理页面 │ │ │
│ │ │ /products │ │ /admin/users │ ← 管理组件集成 │ │
│ │ │ /orders │ │ /admin/tenants │ │ │
│ │ │ /customers │ │ /admin/roles │ │ │
│ │ └─────────────────┘ └─────────────────┘ │ │
│ │ │ │
│ │ 适用: 单应用、小团队、管理员就是业务用户 │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ 方式 B: 独立管理服务 │
│ ──────────────────── │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ CMS App │ │ CRM App │ │ Chat App │ │
│ │ (业务应用) │ │ (业务应用) │ │ (业务应用) │ │
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
│ │ │ │ │
│ └────────────────┼────────────────┘ │
│ │ 共享用户/租户 │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Admin Portal (独立部署) │ │
│ │ admin.example.com │ │
│ │ │ │
│ │ 一个独立的 Blazor 应用,使用管理组件构建 │ │
│ │ 可以扩展自定义页面 │ │
│ │ │ │
│ │ 适用: 多应用、集中管理、专职管理员 │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ 方式 C: 混合模式 │
│ ───────────── │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ 你的业务应用 │ │
│ │ ┌─────────────────┐ ┌─────────────────┐ │ │
│ │ │ 业务页面 │ │ 简化管理页面 │ ← 只放常用功能 │ │
│ │ │ /products │ │ /admin/users │ (用户列表、 │ │
│ │ │ /orders │ │ │ 快速添加) │ │
│ │ └─────────────────┘ └─────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │ │
│ │ 高级管理功能 │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Admin Portal (独立部署) │ │
│ │ │ │
│ │ 完整的管理功能: 审计日志、权限配置、租户管理等 │ │
│ │ 适用: 日常在应用内管理,高级功能去专门后台 │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
独立管理服务的搭建
如果选择部署独立的管理服务,我们会提供:
1. 项目模板
# 使用 CLI 创建管理后台项目
nx new admin-portal --template identity-admin
# 生成的项目结构:
admin-portal/
├── Program.cs # 服务配置
├── appsettings.json # 配置文件
├── Pages/
│ ├── Users/ # 可以扩展或覆盖默认页面
│ ├── Tenants/
│ └── Custom/ # 你的自定义页面
└── Components/ # 自定义组件
2. 最小配置启动
// Program.cs - 最简配置
var builder = WebApplication.CreateBuilder(args);
// 添加管理后台所需服务
builder.Services.AddNextUIIdentity(options =>
{
options.OpenIdConnect = new()
{
Authority = "https://auth.example.com",
ClientId = "admin-portal"
};
options.ProfileApiEndpoint = "https://api.example.com/profiles";
options.MultiTenant = new() { Enabled = true };
});
// 添加管理页面路由
builder.Services.AddNextUIAdminRoutes("/", options =>
{
options.RequireRole = "admin";
options.EnableAllFeatures(); // 启用所有管理功能
});
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapBlazorHub();
app.MapFallbackToPage("/_Host");
app.Run();
3. 扩展自定义功能
@* 添加自定义仪表板 *@
@page "/dashboard"
@attribute [Authorize(Roles = "admin")]
<h1>管理仪表板</h1>
<div class="grid grid-cols-3 gap-4">
<SxCard>
<SxCardHeader>用户统计</SxCardHeader>
<SxCardBody>
<UserStatsWidget /> @* 自定义组件 *@
</SxCardBody>
</SxCard>
<SxCard>
<SxCardHeader>租户统计</SxCardHeader>
<SxCardBody>
<TenantStatsWidget />
</SxCardBody>
</SxCard>
<SxCard>
<SxCardHeader>最近活动</SxCardHeader>
<SxCardBody>
<RecentActivityWidget />
</SxCardBody>
</SxCard>
</div>
@* 下面仍然可以使用预置的管理组件 *@
<h2>待审核用户</h2>
<SxUserList Filter="@(u => u.Status == "pending")"
OnApprove="HandleApprove" />
跨应用共享管理后台
┌─────────────────────────────────────────────────────────────────────┐
│ 共享管理后台架构 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ CMS App │ │ CRM App │ │ Chat App │ │
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
│ │ │ │ │
│ │ 不需要各自实现管理后台 │ │
│ │ │ │ │
│ └────────────────┬┴─────────────────┘ │
│ │ │
│ ▼ │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ Admin Portal (独立应用) │ │
│ │ │ │
│ │ 使用 NextUI.Identity.Admin 组件构建 │ │
│ │ 连接同一个 IdP 和用户资料 API │ │
│ │ │ │
│ │ /users - 统一用户管理 │ │
│ │ /tenants - 统一租户管理 │ │
│ │ /apps - 应用管理 (可选) │ │
│ │ /audit-logs - 统一审计日志 │ │
│ └───────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌───────────────┼───────────────┐ │
│ ▼ ▼ ▼ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────┐ │
│ │ IdP │ │ Profile API │ │ Tenant API │ │
│ │ (Keycloak) │ │ (共享) │ │ (共享) │ │
│ └─────────────┘ └─────────────┘ └─────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
十三、配置指南
配置方式
提供多种配置方式,从简单到复杂:
1. 代码配置 (推荐)
builder.Services.AddNextUIIdentity(options =>
{
// 基础 OIDC 配置
options.OpenIdConnect = new OpenIdConnectOptions
{
Authority = "https://auth.example.com",
ClientId = "my-app",
Scopes = { "openid", "profile", "email" }
};
// 用户资料
options.ProfileStorage = ProfileStorageMode.RemoteApi;
options.ProfileApiEndpoint = "https://api.example.com/profiles";
// 多租户
options.MultiTenant = new MultiTenantOptions
{
Enabled = true,
TenantApiEndpoint = "https://api.example.com/tenants"
};
});
2. 配置文件 (appsettings.json)
{
"NextUI": {
"Identity": {
"OpenIdConnect": {
"Authority": "https://auth.example.com",
"ClientId": "my-app",
"Scopes": ["openid", "profile", "email"]
},
"ProfileStorage": "RemoteApi",
"ProfileApiEndpoint": "https://api.example.com/profiles",
"MultiTenant": {
"Enabled": true,
"TenantApiEndpoint": "https://api.example.com/tenants"
}
}
}
}
// Program.cs
builder.Services.AddNextUIIdentity(
builder.Configuration.GetSection("NextUI:Identity"));
3. 环境变量 (适合容器/CI)
NEXTUI__IDENTITY__OPENIDCONNECT__AUTHORITY=https://auth.example.com
NEXTUI__IDENTITY__OPENIDCONNECT__CLIENTID=my-app
NEXTUI__IDENTITY__PROFILEAPIENDPOINT=https://api.example.com/profiles
配置向导工具
提供 CLI 工具帮助生成配置:
# 交互式配置向导
nx identity init
# 输出:
# NextUI Identity 配置向导
# ========================
#
# ? 选择身份提供者:
# > Keycloak
# Auth0
# Azure AD
# 自定义 OIDC
#
# ? Authority URL: https://auth.example.com
# ? Client ID: my-app
# ? 启用多租户? (y/N): y
# ? 租户 API 端点: https://api.example.com/tenants
#
# 配置已生成到 appsettings.Identity.json
常见 IdP 配置模板
Keycloak
builder.Services.AddNextUIIdentity(options =>
{
options.UseKeycloak(
authority: "https://keycloak.example.com/realms/my-realm",
clientId: "my-blazor-app"
);
});
Auth0
builder.Services.AddNextUIIdentity(options =>
{
options.UseAuth0(
domain: "my-tenant.auth0.com",
clientId: "xxxxxxxxxxxxxxxxxx"
);
});
Azure AD
builder.Services.AddNextUIIdentity(options =>
{
options.UseAzureAD(
tenantId: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
clientId: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
);
});
十四、技术方案详解
14.1 离线支持
问题:用户网络断开时,已登录用户应该如何处理?
方案对比:
| 方案 | 描述 | 优点 | 缺点 |
|---|---|---|---|
| A: 严格在线 | 离线立即失效 | 最安全 | 用户体验差 |
| B: 宽限期 | 离线后一段时间内仍可用 | 平衡安全与体验 | 需要本地存储状态 |
| C: 完全离线 | 长期离线也能用 | 体验最好 | 安全风险高 |
推荐方案:B - 宽限期模式
public class OfflineOptions
{
/// <summary>是否启用离线支持</summary>
public bool Enabled { get; set; } = true;
/// <summary>离线宽限期 (默认 24 小时)</summary>
public TimeSpan GracePeriod { get; set; } = TimeSpan.FromHours(24);
/// <summary>离线时允许的功能级别</summary>
public OfflineAccessLevel AccessLevel { get; set; } = OfflineAccessLevel.ReadOnly;
/// <summary>敏感操作是否需要在线验证</summary>
public bool RequireOnlineForSensitiveOps { get; set; } = true;
}
public enum OfflineAccessLevel
{
/// <summary>完全禁用 - 离线时强制登出</summary>
None,
/// <summary>只读 - 可查看已缓存数据,不能修改</summary>
ReadOnly,
/// <summary>完全访问 - 离线时也可操作,联网后同步</summary>
Full
}
工作流程:
┌─────────────────────────────────────────────────────────────────────┐
│ 离线支持工作流程 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ 用户登录成功 │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ 缓存到 ProtectedBrowserStorage: │ │
│ │ - 用户基本信息 (ID, 名称, 头像) │ │
│ │ - 角色和权限 │ │
│ │ - 最后在线时间戳 │ │
│ │ - 加密的 Refresh Token (可选) │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ 网络断开 │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ 检查本地缓存: │ │
│ │ - 有缓存且未过期 → 使用缓存的用户状态 │ │
│ │ - 有缓存但已过期 → 提示需要联网重新登录 │ │
│ │ - 无缓存 → 视为未登录 │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ 网络恢复 │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ 静默验证: │ │
│ │ - 尝试刷新 Token │ │
│ │ - 成功 → 继续使用 │ │
│ │ - 失败 → 提示重新登录 │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
14.2 Token 缓存与刷新
问题:Access Token 通常很短命(5-60分钟),如何管理?
Token 类型说明:
| Token 类型 | 用途 | 有效期 | 存储位置 |
|---|---|---|---|
| ID Token | 证明用户身份 | 短 (5-60分钟) | 内存 |
| Access Token | 调用 API | 短 (5-60分钟) | 内存或安全存储 |
| Refresh Token | 获取新 Token | 长 (天/周/月) | 安全存储 |
存储方案对比:
| 存储位置 | 安全性 | 持久性 | 推荐 |
|---|---|---|---|
| 内存 | 最高 | 页面刷新丢失 | Access Token |
| sessionStorage | 中 | 关闭标签页丢失 | 不推荐 |
| localStorage | 低 | 永久 | 不推荐 (XSS 风险) |
| ProtectedBrowserStorage | 高 | 可配置 | Refresh Token |
| HttpOnly Cookie | 最高 | 可配置 | 服务端渲染时 |
推荐方案:
public class TokenStorageOptions
{
/// <summary>Access Token 存储位置</summary>
public TokenStorage AccessTokenStorage { get; set; } = TokenStorage.Memory;
/// <summary>Refresh Token 存储位置</summary>
public TokenStorage RefreshTokenStorage { get; set; } = TokenStorage.ProtectedBrowserStorage;
/// <summary>Token 刷新策略</summary>
public TokenRefreshStrategy RefreshStrategy { get; set; } = TokenRefreshStrategy.Proactive;
/// <summary>提前多久刷新 (默认过期前 5 分钟)</summary>
public TimeSpan RefreshBeforeExpiry { get; set; } = TimeSpan.FromMinutes(5);
}
public enum TokenStorage
{
Memory, // 仅内存,最安全
ProtectedBrowserStorage, // Blazor 加密存储
HttpOnlyCookie // 服务端管理
}
public enum TokenRefreshStrategy
{
/// <summary>按需刷新 - 收到 401 时才刷新</summary>
OnDemand,
/// <summary>主动刷新 - 过期前自动刷新 (推荐)</summary>
Proactive,
/// <summary>后台刷新 - 定时检查并刷新</summary>
Background
}
刷新流程:
┌─────────────────────────────────────────────────────────────────────┐
│ Token 刷新流程 (主动刷新) │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ API 调用前 │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ 检查 Access Token: │ │
│ │ - 有效且未接近过期 → 直接使用 │ │
│ │ - 接近过期 (< 5分钟) → 触发静默刷新 │ │
│ │ - 已过期 → 必须刷新后才能用 │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ (需要刷新) │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ 使用 Refresh Token 请求新 Token: │ │
│ │ POST /oauth/token │ │
│ │ { grant_type: "refresh_token", refresh_token: "xxx" } │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │ │
│ ├─── 成功 ──► 更新内存中的 Access Token,继续 API 调用 │
│ │ │
│ └─── 失败 ──► Refresh Token 也过期,需要重新登录 │
│ │
└─────────────────────────────────────────────────────────────────────┘
14.3 权限模型
三种主流权限模型解释:
RBAC (Role-Based Access Control) - 基于角色
最简单、最常用的模型
用户 ──拥有──► 角色 ──包含──► 权限
示例:
用户 "张三"
└── 角色 "编辑"
├── 权限: 创建文章
├── 权限: 编辑文章
└── 权限: 删除自己的文章
代码:
if (user.IsInRole("editor")) { ... }
if (user.HasPermission("article:create")) { ... }
ABAC (Attribute-Based Access Control) - 基于属性
更灵活,基于多个属性动态判断
判断依据:
- 用户属性 (部门、职级、地区)
- 资源属性 (所有者、分类、敏感级别)
- 环境属性 (时间、IP、设备)
- 操作属性 (读、写、删除)
示例:
规则: "用户可以编辑自己部门的、非机密的文档"
检查:
user.Department == document.Department
AND document.Classification != "机密"
AND action == "edit"
代码:
if (authz.CanAccess(user, document, "edit")) { ... }
PBAC (Policy-Based Access Control) - 基于策略
ABAC 的正式化版本,使用策略语言定义规则
策略示例 (类似 AWS IAM):
{
"Effect": "Allow",
"Action": ["document:read", "document:edit"],
"Resource": "documents/*",
"Condition": {
"StringEquals": { "document:department": "${user:department}" },
"StringNotEquals": { "document:classification": "机密" }
}
}
对比总结:
| 特性 | RBAC | ABAC | PBAC |
|---|---|---|---|
| 复杂度 | 低 | 中 | 高 |
| 灵活性 | 低 | 高 | 最高 |
| 维护成本 | 低 | 中 | 高 |
| 适用场景 | 大多数应用 | 复杂业务规则 | 企业级/合规要求 |
| 学习成本 | 低 | 中 | 高 |
推荐方案:RBAC + 可选 ABAC 扩展
// 基础: RBAC (开箱即用)
public interface ICurrentUser
{
bool IsInRole(string role);
bool HasPermission(string permission);
}
// 扩展: ABAC (按需启用)
public interface IAuthorizationService
{
/// <summary>
/// 基于属性的访问控制检查
/// </summary>
Task<bool> AuthorizeAsync<TResource>(
ICurrentUser user,
TResource resource,
string action,
IDictionary<string, object>? context = null);
}
// 使用示例
// 简单场景 - RBAC
if (user.IsInRole("admin")) { /* 允许 */ }
// 复杂场景 - ABAC
var canEdit = await authz.AuthorizeAsync(user, document, "edit", new {
RequestTime = DateTime.Now,
ClientIP = httpContext.Connection.RemoteIpAddress
});
配置:
public class AuthorizationOptions
{
/// <summary>权限模型</summary>
public PermissionModel Model { get; set; } = PermissionModel.RBAC;
/// <summary>ABAC 规则提供者 (当 Model = ABAC 时)</summary>
public IAbacPolicyProvider? PolicyProvider { get; set; }
}
public enum PermissionModel
{
/// <summary>基于角色 (推荐大多数场景)</summary>
RBAC,
/// <summary>基于属性 (复杂业务场景)</summary>
ABAC,
/// <summary>混合模式 - RBAC 为主,特定场景用 ABAC</summary>
Hybrid
}
14.4 租户数据隔离
问题:多租户系统中,不同租户的数据如何隔离?
三种隔离级别:
┌─────────────────────────────────────────────────────────────────────┐
│ 数据隔离级别对比 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ Level 1: 逻辑隔离 (推荐) │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ 共享数据库 │ │
│ │ ┌─────────────────────────────────────────────────────┐ │ │
│ │ │ documents 表 │ │ │
│ │ │ ┌──────────┬───────────┬─────────────────────────┐ │ │ │
│ │ │ │ id │ tenant_id │ content │ │ │ │
│ │ │ ├──────────┼───────────┼─────────────────────────┤ │ │ │
│ │ │ │ 1 │ acme │ Acme 的文档... │ │ │ │
│ │ │ │ 2 │ techcorp │ TechCorp 的文档... │ │ │ │
│ │ │ └──────────┴───────────┴─────────────────────────┘ │ │ │
│ │ └─────────────────────────────────────────────────────┘ │ │
│ │ 优点: 简单、成本低、易维护 │ │
│ │ 缺点: 需要每个查询都带 tenant_id │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ Level 2: Schema 隔离 │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ 共享数据库 │ │
│ │ ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ │ │
│ │ │ schema: acme │ │schema:techcorp│ │ schema: ... │ │ │
│ │ │ - documents │ │ - documents │ │ - documents │ │ │
│ │ │ - users │ │ - users │ │ - users │ │ │
│ │ └───────────────┘ └───────────────┘ └───────────────┘ │ │
│ │ 优点: 隔离性更好、查询不需要 tenant_id │ │
│ │ 缺点: 管理复杂、迁移困难 │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ Level 3: 数据库隔离 │
│ ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ │
│ │ DB: acme │ │ DB: techcorp │ │ DB: ... │ │
│ │ - documents │ │ - documents │ │ - documents │ │
│ │ - users │ │ - users │ │ - users │ │
│ └───────────────┘ └───────────────┘ └───────────────┘ │
│ 优点: 最强隔离、可独立备份/恢复、满足合规要求 │
│ 缺点: 成本高、运维复杂、跨租户查询困难 │
│ │
└─────────────────────────────────────────────────────────────────────┘
对比:
| 特性 | 逻辑隔离 | Schema 隔离 | 数据库隔离 |
|---|---|---|---|
| 成本 | 最低 | 中 | 最高 |
| 隔离性 | 软隔离 | 中 | 最强 |
| 运维复杂度 | 低 | 中 | 高 |
| 扩展性 | 好 | 中 | 需要规划 |
| 合规性 | 一般 | 较好 | 最好 |
| 适用场景 | 大多数 SaaS | 中型企业 | 金融/医疗 |
推荐方案:逻辑隔离 + 可选升级
public class TenantIsolationOptions
{
/// <summary>隔离级别</summary>
public TenantIsolationLevel Level { get; set; } = TenantIsolationLevel.Logical;
/// <summary>是否自动应用租户过滤器 (EF Core)</summary>
public bool AutoApplyFilter { get; set; } = true;
/// <summary>允许跨租户查询的角色</summary>
public List<string> CrossTenantRoles { get; set; } = new() { "super_admin" };
}
public enum TenantIsolationLevel
{
/// <summary>逻辑隔离 - 共享表,tenant_id 列 (推荐)</summary>
Logical,
/// <summary>Schema 隔离 - 每租户独立 schema</summary>
Schema,
/// <summary>数据库隔离 - 每租户独立数据库</summary>
Database
}
EF Core 自动租户过滤:
// DbContext 配置
public class AppDbContext : DbContext
{
private readonly ITenantContext _tenantContext;
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// 自动为所有实现 ITenantEntity 的实体添加过滤器
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
{
if (typeof(ITenantEntity).IsAssignableFrom(entityType.ClrType))
{
modelBuilder.Entity(entityType.ClrType)
.HasQueryFilter(e => ((ITenantEntity)e).TenantId == _tenantContext.CurrentTenant!.TenantId);
}
}
}
}
// 实体基类
public interface ITenantEntity
{
string TenantId { get; set; }
}
// 使用 - 查询自动过滤,开发者无需手动加 where
var myDocuments = await db.Documents.ToListAsync();
// 自动变成: SELECT * FROM Documents WHERE TenantId = 'current_tenant'
十五、设计决策总结
根据讨论,以下是关键设计决策:
| 问题 | 决策 | 理由 |
|---|---|---|
| 离线支持 | 宽限期模式 (24h) | 平衡安全与用户体验 |
| Token 存储 | Access Token 内存,Refresh Token 加密存储 | 安全性最优 |
| Token 刷新 | 主动刷新 (过期前 5 分钟) | 用户无感知 |
| 权限模型 | RBAC 为主 + 可选 ABAC | 简单场景开箱即用,复杂场景可扩展 |
| 租户隔离 | 逻辑隔离 (tenant_id 列) | 成本低、灵活、适合大多数场景 |
| 管理后台 | 三层组件 (可集成或独立部署) | 灵活性 + 开箱即用 |
15.1 Token 内容策略
Token 应保持精简,只包含认证和授权所需的核心数据。
| 数据类型 | 存放位置 | 理由 |
|---|---|---|
| 用户基本信息 (sub, name, email) | Token | OIDC 标准 claims,认证必需 |
| 全局角色 (Realm Roles) | Token | 频繁使用,避免每次请求 API |
| 应用角色 (Client Roles) | Token | Keycloak 自动只含当前应用角色,大小可控 |
| 当前组织 ID | Token | 每次请求都需要,放 Token 避免额外调用 |
| 组织列表 | API 获取 | 可能较多,组织切换时再获取 |
| 组织详情 (名称、Logo 等) | API 获取 | 不频繁使用,按需获取 |
| 用户偏好 | API 获取 | 数据量可能很大,分层存储 |
原则: Token 小而快,API 获取大而全。Token 解析在每个请求都发生,保持精简才能高效。
15.2 多组织 (Multi-Org) 架构
用户可以属于多个组织,但一次只能在一个组织上下文中操作。
┌─────────────────────────────────────────────────────────────────────┐
│ 多组织切换流程 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ 1. 用户登录 │
│ └── Token 中包含默认组织 ID (org_id claim) │
│ │
│ 2. 用户通过 SxTenantSwitcher 切换组织 │
│ └── UI 调用 ITenantContext.SwitchTenantAsync(orgId) │
│ │
│ 3. 前端发起 API 请求 │
│ └── 自动附加 Header: X-Organization-Id: {selected_org_id} │
│ │
│ 4. 后端验证 │
│ ├── 检查 Token 有效性 │
│ ├── 检查用户是否属于该组织 (从 Token claims 或数据库) │
│ └── 执行业务逻辑,数据自动按组织隔离 │
│ │
└─────────────────────────────────────────────────────────────────────┘
为什么不用 Token Exchange?
Token Exchange (RFC 8693) 虽然更"标准",但实际开发中:
- 配置复杂,需要 Keycloak 开启相应功能
- 每次切换组织都要和 IdP 交互,延迟高
- 对于"用户属于哪些组织"这种应用级数据,Header 方案更简单实用
15.3 多应用角色 (Multi-App Roles)
使用 Keycloak Client Roles 原生解决不同应用有不同角色的问题。
Keycloak Realm: my-company
├── Realm Roles (全局角色)
│ ├── employee → 所有员工
│ └── admin → 超级管理员
│
├── Client: cms-app (内容管理系统)
│ └── Client Roles
│ ├── editor → 可编辑内容
│ ├── publisher → 可发布内容
│ └── reviewer → 可审核内容
│
├── Client: crm-app (客户管理系统)
│ └── Client Roles
│ ├── sales → 销售人员
│ ├── support → 客服人员
│ └── manager → 销售经理
│
└── Client: analytics-app (数据分析)
└── Client Roles
├── viewer → 只读查看
└── analyst → 完整分析权限
工作原理:
- 用户 Alice 被分配:
cms-app/editor,crm-app/sales - Alice 登录 CMS 应用时,Token 只包含:
["employee", "editor"] - Alice 登录 CRM 应用时,Token 只包含:
["employee", "sales"] - 无需额外配置,Keycloak 自动处理
15.4 内外部用户区分
内部员工和外部客户可以使用同一个 Keycloak Realm,通过 Groups 和 Client Access 控制区分。
Keycloak Realm: my-company
├── Groups
│ ├── internal-users/ → 内部员工组
│ │ ├── engineering
│ │ ├── marketing
│ │ └── sales
│ │
│ └── external-users/ → 外部用户组
│ ├── customers
│ └── partners
│
├── Client: internal-portal
│ └── Access: 只允许 internal-users 组成员
│
├── Client: customer-portal
│ └── Access: 只允许 external-users 组成员
│
└── Client: public-docs
└── Access: 所有用户可访问
配置方式 (Keycloak):
- 创建 Groups 层级结构
- 每个 Client 的 Authorization 设置中配置 "Client Access" 策略
- 基于 Group 或 Role 限制访问
优势:
- 同一 Realm,SSO 统一管理
- Groups 提供灵活的组织结构
- Client 级别访问控制精确限制
- 无需维护多个 Realm
15.5 层级用户偏好 (Hierarchical Preferences)
用户偏好支持三级继承,从全局到应用逐级覆盖。
偏好作用域层级:
/ (Global - 所有应用的默认值)
└── /devtools (Group - devtools 类应用共享)
└── /devtools/ide (App - IDE 应用专属)
继承规则:
├── 子级未设置的值,继承父级
├── 子级设置的值,覆盖父级
└── Clear 操作只清除当前级别,恢复继承
示例:
/ → Theme: system, Language: zh-CN
/devtools → Theme: dark (Language 继承 zh-CN)
/devtools/ide → Language: en-US (Theme 继承 dark)
解析 /devtools/ide 结果:
Theme: dark (from /devtools)
Language: en-US (from /devtools/ide)
接口: IUserPreferencesService
GetPreferencesAsync(scopePath)- 获取解析后的偏好GetRawPreferencesAsync(scopePath)- 获取指定作用域的原始偏好UpdatePreferencesAsync(update, scopePath)- 更新指定作用域的偏好ClearPreferencesAsync(scopePath)- 清除指定作用域的偏好
默认配置 (开箱即用)
// 所有默认值都是推荐配置,大多数场景无需修改
builder.Services.AddNextUIIdentity(options =>
{
// 只需配置 OIDC
options.OpenIdConnect = new()
{
Authority = "https://auth.example.com",
ClientId = "my-app"
};
// 以下都是默认值,列出来是为了说明:
// options.Offline.Enabled = true;
// options.Offline.GracePeriod = TimeSpan.FromHours(24);
// options.TokenStorage.RefreshStrategy = TokenRefreshStrategy.Proactive;
// options.Authorization.Model = PermissionModel.RBAC;
});
十六、总结
设计原则
| 原则 | 说明 |
|---|---|
| 专注客户端 | 不做认证服务,只做 OIDC 客户端集成 |
| 不重复造轮子 | 用户管理、密码、MFA 等交给 IdP |
| 分层架构 | Core (接口) → Identity (实现) → Admin (管理组件) |
| 灵活 + 开箱即用 | 组件可单独使用,也可一键集成完整方案 |
| 多租户优先 | 原生支持用户属于多个组织的场景 |
包结构
NextUI.Core - ICurrentUser 等接口定义
NextUI.Identity - OIDC 集成、用户资料、多租户核心
NextUI.Identity.EF - Entity Framework 持久化 (可选)
NextUI.Identity.Admin - 管理组件和预组装页面 (可选)
NextUI.Blazor - 身份相关 UI 组件 (SxUserMenu 等)
核心价值
| 价值 | 描述 |
|---|---|
| 开箱即用 | 配置 IdP 参数即可,无需写代码 |
| 灵活扩展 | 每层都可替换或扩展 |
| 多应用 SSO | 天然支持,依赖 IdP |
| 多租户 | 原生支持用户多组织关系 |
| 管理后台 | 组件 + 预组装页面 + 自动路由,按需选用 |
十七、共享配置文件 (Shared Profile) 详解
17.1 概述
共享配置文件功能允许用户的通用数据(头像、语言、主题等)在多个应用之间同步,提供一致的用户体验。
17.2 架构
┌─────────────────────────────────────────────────────────────────────┐
│ 共享配置文件架构 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ CMS App │ │ CRM App │ │ Chat App │ │
│ │ (NextUI) │ │ (NextUI) │ │ (NextUI) │ │
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
│ │ │ │ │
│ │ ISharedProfileService │ │
│ │ │ │ │
│ └────────────────┬┴─────────────────┘ │
│ │ │
│ ▼ │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ Shared Profile API │ │
│ │ │ │
│ │ GET /api/shared-profiles/{userId} 获取配置 │ │
│ │ PATCH /api/shared-profiles/{userId} 部分更新 │ │
│ │ PUT /api/shared-profiles/{userId} 完整更新 │ │
│ └───────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ Shared Profile Store │ │
│ │ (Database / Cache / etc.) │ │
│ └───────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
17.3 配置
builder.Services.AddNextUIIdentity(options =>
{
options.OpenIdConnect = new OpenIdConnectOptions
{
Authority = "https://auth.example.com",
ClientId = "my-app"
};
// 启用共享配置文件
options.SharedProfile = new SharedProfileOptions
{
Enabled = true,
ApiEndpoint = "https://api.example.com/shared-profiles",
// 选择要同步的字段
SyncFields = new List<string>
{
SharedProfileFields.Avatar,
SharedProfileFields.DisplayName,
SharedProfileFields.PreferredLanguage,
SharedProfileFields.ThemePreference,
SharedProfileFields.Timezone
},
// 登录时自动同步
SyncOnLogin = true,
// 更改时立即同步
ImmediateSync = true,
// 冲突解决策略
ConflictResolution = ConflictResolution.LastWriteWins
};
});
17.4 使用示例
读取共享配置文件
@inject ISharedProfileService SharedProfile
@code {
private SharedProfile? _profile;
protected override async Task OnInitializedAsync()
{
_profile = await SharedProfile.GetSharedProfileAsync();
}
}
<div>
@if (_profile != null)
{
<img src="@_profile.AvatarUrl" alt="Avatar" />
<p>语言: @_profile.PreferredLanguage</p>
<p>主题: @_profile.ThemePreference</p>
}
</div>
更新共享配置文件
@inject ISharedProfileService SharedProfile
// 更新单个字段
await SharedProfile.UpdateFieldAsync(SharedProfileFields.ThemePreference, "dark");
// 批量更新
await SharedProfile.UpdateSharedProfileAsync(new Dictionary<string, object?>
{
{ SharedProfileFields.PreferredLanguage, "zh-CN" },
{ SharedProfileFields.Timezone, "Asia/Shanghai" }
});
监听配置变更
@inject ISharedProfileService SharedProfile
protected override void OnInitialized()
{
SharedProfile.ProfileChanged += OnProfileChanged;
}
private void OnProfileChanged(SharedProfile profile)
{
// 配置已更新,刷新 UI
StateHasChanged();
}
public void Dispose()
{
SharedProfile.ProfileChanged -= OnProfileChanged;
}
17.5 冲突解决
当多个应用同时修改同一字段时,需要冲突解决策略:
| 策略 | 说明 | 适用场景 |
|---|---|---|
| LastWriteWins | 最后的写入覆盖之前的 | 大多数场景(默认) |
| ServerWins | 服务端值始终保留 | 需要集中控制 |
| ClientWins | 本地值始终保留 | 离线优先应用 |
17.6 自定义字段
除了预定义字段,还可以添加自定义字段:
// 配置自定义字段
options.SharedProfile.SyncFields.Add("customField1");
options.SharedProfile.SyncFields.Add("customField2");
// 使用自定义字段
await SharedProfile.UpdateSharedProfileAsync(new Dictionary<string, object?>
{
{ "customField1", "value1" },
{ "customField2", 123 }
});
// 读取自定义字段
var value = SharedProfile.GetField<string>("customField1");
十八、MAUI 平台注意事项
18.1 平台特定实现
NextUI.Identity 在 MAUI 平台需要特定的实现,因为移动平台的认证机制与 Web 不同。
平台差异
| 功能 | Web (Blazor WebAssembly) | MAUI |
|---|---|---|
| 认证流程 | OIDC redirect flow | WebAuthenticator |
| Token 存储 | ProtectedBrowserStorage | SecureStorage |
| 会话管理 | Cookie/LocalStorage | 平台 KeyChain/KeyStore |
| 深度链接 | URL callback | App scheme callback |
18.2 MAUI 配置
// MauiProgram.cs
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp<App>()
.ConfigureFonts(fonts => { ... });
// 注册 MAUI 特定的 Identity 服务
builder.Services.AddScoped<IUserPreferences, MauiUserPreferences>();
builder.Services.AddScoped<IIdentityStorage, MauiIdentityStorage>();
builder.Services.AddScoped<ICurrentUser, MauiCurrentUser>();
builder.Services.AddScoped<IIdentityService, MauiIdentityService>();
return builder.Build();
}
18.3 MauiIdentityStorage 实现
using Microsoft.Maui.Storage;
public class MauiIdentityStorage : IIdentityStorage
{
public async Task<T?> GetAsync<T>(string key)
{
var json = await SecureStorage.Default.GetAsync(key);
if (string.IsNullOrEmpty(json))
return default;
return JsonSerializer.Deserialize<T>(json);
}
public async Task SetAsync<T>(string key, T value)
{
var json = JsonSerializer.Serialize(value);
await SecureStorage.Default.SetAsync(key, json);
}
public Task RemoveAsync(string key)
{
SecureStorage.Default.Remove(key);
return Task.CompletedTask;
}
}
18.4 MauiIdentityService 实现
using Microsoft.Maui.Authentication;
public class MauiIdentityService : IIdentityService
{
private readonly IdentityOptions _options;
private readonly MauiCurrentUser _currentUser;
private readonly MauiIdentityStorage _storage;
public ICurrentUser CurrentUser => _currentUser;
public event Action<ICurrentUser>? UserLoggedIn;
public event Action? UserLoggedOut;
public async Task LoginAsync(string? returnUrl = null, string? provider = null)
{
var oidc = _options.OpenIdConnect!;
var authResult = await WebAuthenticator.Default.AuthenticateAsync(
new Uri($"{oidc.Authority}/authorize?" +
$"client_id={oidc.ClientId}&" +
$"redirect_uri={GetCallbackUrl()}&" +
$"response_type=code&" +
$"scope={string.Join(" ", oidc.Scopes)}"),
new Uri(GetCallbackUrl()));
if (authResult != null)
{
// Exchange code for tokens
await ExchangeCodeForTokensAsync(authResult.Properties["code"]);
}
}
public async Task LogoutAsync(string? returnUrl = null)
{
await _storage.RemoveAsync("access_token");
await _storage.RemoveAsync("refresh_token");
_currentUser.Clear();
UserLoggedOut?.Invoke();
}
private string GetCallbackUrl()
{
// 使用 app scheme
return "myapp://callback";
}
}
18.5 平台特定配置
Android (AndroidManifest.xml)
<activity android:name="Microsoft.Maui.Authentication.WebAuthenticatorCallbackActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="myapp" android:host="callback" />
</intent-filter>
</activity>
iOS (Info.plist)
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>My App</string>
<key>CFBundleURLSchemes</key>
<array>
<string>myapp</string>
</array>
</dict>
</array>
十九、快速入门指南
19.1 安装
# 使用 NuGet
dotnet add package NextUI.Identity
# 或使用项目引用
<ProjectReference Include="../NextUI.Identity/NextUI.Identity.csproj" />
19.2 基础配置 (5 分钟上手)
1. 配置 Program.cs
using NextUI.Identity;
var builder = WebAssemblyHostBuilder.CreateDefault(args);
// 添加 NextUI Identity
builder.Services.AddNextUIIdentity(options =>
{
options.UseKeycloak(
authority: "https://auth.example.com/realms/my-realm",
clientId: "my-blazor-app"
);
});
// 其他服务...
builder.Services.AddNextDesignSystem();
await builder.Build().RunAsync();
2. 更新 App.razor
<CascadingAuthenticationState>
<Router AppAssembly="@typeof(App).Assembly">
<Found Context="routeData">
<AuthorizeRouteView RouteData="@routeData"
DefaultLayout="@typeof(MainLayout)">
<NotAuthorized>
<p>请先登录</p>
<SxLoginButton />
</NotAuthorized>
</AuthorizeRouteView>
</Found>
<NotFound>
<p>页面未找到</p>
</NotFound>
</Router>
</CascadingAuthenticationState>
3. 添加登录按钮
@* MainLayout.razor *@
<SxAppShell>
<NavBar>
<SxNavBar>
<UserMenu>
<SxIdentityUserBar />
</UserMenu>
</SxNavBar>
</NavBar>
<Body>
@Body
</Body>
</SxAppShell>
19.3 常见场景
场景 1: 保护特定页面
@page "/admin"
@attribute [Authorize(Roles = "admin")]
<h1>管理页面</h1>
<p>只有管理员可以看到此页面</p>
场景 2: 条件渲染
@inject ICurrentUser User
@if (User.IsAuthenticated)
{
<p>欢迎, @User.DisplayName!</p>
@if (User.IsInRole("admin"))
{
<SxButton OnClick="OpenAdminPanel">管理面板</SxButton>
}
}
else
{
<SxLoginButton Text="登录" />
}
场景 3: 调用受保护 API
@inject IIdentityService Identity
@inject HttpClient Http
private async Task CallApiAsync()
{
var token = await Identity.GetAccessTokenAsync();
if (token != null)
{
Http.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", token);
var response = await Http.GetAsync("/api/protected");
// ...
}
}
场景 4: 多租户切换
@inject ITenantContext TenantContext
<SxSelect @bind-Value="_selectedTenant" Label="选择组织">
@foreach (var membership in TenantContext.Memberships)
{
<SxSelectItem Value="@membership.Tenant.TenantId">
@membership.Tenant.DisplayName
</SxSelectItem>
}
</SxSelect>
@code {
private string? _selectedTenant;
private async Task OnTenantChange(string tenantId)
{
await TenantContext.SwitchTenantAsync(tenantId);
}
}
19.4 IdP 配置示例
Keycloak
builder.Services.AddNextUIIdentity(options =>
{
options.UseKeycloak(
authority: "https://keycloak.example.com/realms/my-realm",
clientId: "my-blazor-app",
additionalScopes: new[] { "roles", "profile" }
);
});
Auth0
builder.Services.AddNextUIIdentity(options =>
{
options.UseAuth0(
domain: "my-tenant.auth0.com",
clientId: "your-client-id",
audience: "https://api.example.com"
);
});
Azure AD
builder.Services.AddNextUIIdentity(options =>
{
options.UseAzureAD(
tenantId: "your-tenant-id",
clientId: "your-client-id"
);
});
二十、API 参考
20.1 ICurrentUser
public interface ICurrentUser
{
bool IsAuthenticated { get; }
string? UserId { get; }
string? DisplayName { get; }
string? Email { get; }
string? AvatarUrl { get; }
IReadOnlyList<string> Roles { get; }
IReadOnlyDictionary<string, string> Claims { get; }
bool IsInRole(string role);
bool HasPermission(string permission);
event Action? Changed;
}
20.2 IIdentityService
public interface IIdentityService
{
ICurrentUser CurrentUser { get; }
Task LoginAsync(string? returnUrl = null, string? provider = null);
Task LogoutAsync(string? returnUrl = null);
Task<bool> RefreshTokenAsync();
Task<string?> GetAccessTokenAsync();
event Action<ICurrentUser>? UserLoggedIn;
event Action? UserLoggedOut;
}
20.3 ISharedProfileService
public interface ISharedProfileService
{
bool IsEnabled { get; }
Task<SharedProfile?> GetSharedProfileAsync();
Task UpdateSharedProfileAsync(IDictionary<string, object?> updates);
Task UpdateFieldAsync(string field, object? value);
Task SyncFromServerAsync();
T? GetField<T>(string field, T? defaultValue = default);
event Action<SharedProfile>? ProfileChanged;
}
20.4 ITenantContext
public interface ITenantContext
{
ITenant? CurrentTenant { get; }
IReadOnlyList<ITenantMembership> Memberships { get; }
Task SwitchTenantAsync(string tenantId);
IReadOnlyList<string> GetCurrentRoles();
bool HasPermission(string permission);
event Action<ITenant?>? TenantChanged;
}
二十一、故障排除
常见问题
Q: 登录后跳转不回应用
检查 OIDC 配置中的 CallbackPath 是否与 IdP 配置匹配:
options.OpenIdConnect = new OpenIdConnectOptions
{
// ...
CallbackPath = "/signin-oidc", // 确保与 IdP 中配置的回调 URL 一致
};
Q: Token 刷新失败
检查 Refresh Token 是否启用:
options.OpenIdConnect.Scopes.Add("offline_access");
Q: 角色检查不生效
确认角色 Claim 名称配置正确:
options.Authorization.RolesClaimName = "roles"; // Keycloak 默认
// 或
options.Authorization.RolesClaimName = "http://schemas.microsoft.com/ws/2008/06/identity/claims/role"; // Azure AD
Q: 共享配置文件不同步
- 检查
SharedProfile.Enabled = true - 确认
ApiEndpoint可访问 - 检查用户是否已登录
二十二、下一步
- ✅ 确认此设计方案符合需求
- ✅ Phase 1-3 基础实现完成
- 接下来的工作:
- 创建单元测试 (Phase 2)
- 创建 Mock Provider (Phase 3)
- Workbench 集成 (Phase 4)
- Samples 集成 (Phase 5)
- 部署脚本 (Phase 6-7)
- CLI 支持 (Phase 8)