admin/src/SimpleAdmin.Api/Auth/AuthService.cs
zzh 74151b0738 feat(customer): 列表返回 Id 并支持管理员代登客户门户
补充客户分页 Id 字段与 customer-oms-login 接口,修复管理端删除/免密登录;公告通知增加接收人候选项与客户接收类型。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 18:35:46 +08:00

731 lines
26 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using Fake.Application;
using Fake.Domain.Exceptions;
using Fake.Rbac.Application.Dtos.Auth;
using Fake.Rbac.Application.Dtos.Menu;
using Fake.Rbac.Application.Dtos.User;
using Fake.Rbac.Application.Jwt;
using Fake.Rbac.Application.Services;
using Fake.Rbac.Domain.Managers;
using Fake.Rbac.Domain.UserAggregate;
using Fake.Rbac.Infrastructure;
using Fake.Users;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Oms.Application.Shared;
using Oms.Domain.CustomersAggregate.Entities;
using Oms.Domain.CustomersAggregate.Enums;
using Oms.Domain.CustomersAggregate.Repositories;
using SimpleAdmin.Api.WeChat.Dtos;
using SimpleAdmin.Api.WeChat.Services;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Processing;
namespace SimpleAdmin.Api.Auth;
/// <summary>
/// 接管 OMS 与 RBAC 共用的认证接口,补充客户登录和业务主体 claims 解析。
/// </summary>
[Authorize]
[ApiExplorerSettings(GroupName = "RBAC")]
public partial class AuthService(
AccountManager accountManager,
UserService userService,
IJwtService jwtService,
IUserRepository userRepository,
IWebHostEnvironment webHostEnvironment,
IHttpContextAccessor httpContextAccessor,
FakeRbacDbContext rbacDbContext,
ICustomerRepository customerRepository,
IWeChatLoginSessionStore weChatLoginSessionStore)
: ApplicationService
{
private Guid CurrentUserGuid => CurrentUser.FindClaimValueOrNull(ClaimTypes.NameIdentifier)!.To<Guid>();
/// <summary>
/// 用户或客户登录。
/// </summary>
/// <param name="input">入参:登录类型、账号和密码。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>登录令牌、刷新令牌和用户基础信息。</returns>
[AllowAnonymous]
[HttpPost("/rbac/auth/login")]
public async Task<LoginResultDto> LoginAsync([FromBody] OmsLoginInput input, CancellationToken cancellationToken = default)
{
return input.Type switch
{
OmsLoginType.User => await LoginAdminAsync(input.Account, input.Password, cancellationToken),
OmsLoginType.Customer => await LoginCustomerAsync(input.Account, input.Password, cancellationToken),
_ => throw new BusinessException("不支持的登录类型")
};
}
/// <summary>
/// 管理员代登客户门户(免密)。
/// </summary>
/// <param name="input">入参:目标客户主键。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>客户门户访问令牌。</returns>
[HttpPost("/rbac/auth/customer-oms-login")]
public async Task<LoginResultDto> CustomerOmsLoginAsync(
[FromBody] OmsCustomerOmsLoginInput input,
CancellationToken cancellationToken = default)
{
EnsureAdminPrincipal();
var customer = await customerRepository.FirstOrDefaultAsync(x => x.Id == input.CustomerId, cancellationToken);
if (customer == null)
{
throw new BusinessException("客户不存在");
}
if (customer.ParentCustomerId.HasValue)
{
ValidateSubCustomerStatus(customer);
var mainCustomer = await customerRepository.FirstOrDefaultAsync(
x => x.Id == customer.ParentCustomerId.Value,
cancellationToken);
if (mainCustomer == null)
{
throw new BusinessException("主客户不存在");
}
ValidateMainCustomerStatus(mainCustomer);
}
else
{
ValidateMainCustomerStatus(customer);
}
var userGuid = await GetRbacUserGuidByMapIdAsync(customer.UserId, cancellationToken);
if (!userGuid.HasValue)
{
throw new BusinessException("客户未关联用户");
}
var claims = await BuildClaimsAsync(userGuid.Value, OmsLoginType.Customer, cancellationToken);
return BuildLoginResult(userGuid.Value, claims);
}
/// <summary>
/// 微信扫码登录轮询状态(key 是否已有绑定账号)。
/// </summary>
[AllowAnonymous]
[HttpGet("/rbac/auth/wx-login/status")]
public async Task<WxLoginStatusOutput> WxLoginStatusAsync(
[FromQuery] string loginUuid,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(loginUuid))
{
throw new BusinessException("缺少 loginUuid");
}
var session = await weChatLoginSessionStore.GetSessionAsync(loginUuid.Trim(), cancellationToken);
if (session == null)
{
return new WxLoginStatusOutput { Ready = false };
}
return new WxLoginStatusOutput
{
Ready = true,
Account = string.IsNullOrWhiteSpace(session.CustomerCode) ? session.OpenId : session.CustomerCode
};
}
/// <summary>
/// 微信扫码登录(轮询)。
/// </summary>
[AllowAnonymous]
[HttpPost("/rbac/auth/wx-login")]
public async Task<LoginResultDto> WxLoginAsync(
[FromBody] OmsWxLoginInput input,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(input.LoginUuid))
{
throw new BusinessException("缺少 loginUuid");
}
var loginKey = input.LoginUuid.Trim();
var session = await weChatLoginSessionStore.GetSessionAsync(loginKey, cancellationToken);
if (session == null)
{
throw new BusinessException("请扫码登录");
}
var customer = await ResolveCustomerFromWxSessionAsync(session, cancellationToken);
if (customer == null)
{
throw new BusinessException("未绑定微信,请先注册或绑定账号");
}
if (customer.ParentCustomerId.HasValue)
{
throw new BusinessException("子客户不支持微信扫码登录");
}
ValidateMainCustomerStatus(customer);
customer.LastLoginIp = httpContextAccessor.HttpContext?.Connection.RemoteIpAddress?.ToString();
customer.LastLoginTime = DateTime.Now;
await customerRepository.UpdateAsync(customer, cancellationToken);
var userGuid = await GetRbacUserGuidByMapIdAsync(customer.UserId, cancellationToken);
if (!userGuid.HasValue)
{
throw new BusinessException("客户未关联用户");
}
await weChatLoginSessionStore.RemoveAsync(loginKey, cancellationToken);
var claims = await BuildClaimsAsync(userGuid.Value, OmsLoginType.Customer, cancellationToken);
return BuildLoginResult(userGuid.Value, claims);
}
private async Task<Customer?> ResolveCustomerFromWxSessionAsync(
WeChatLoginSessionPayload session,
CancellationToken cancellationToken)
{
if (session.CustomerId > 0)
{
return await customerRepository.FirstOrDefaultAsync(
x => x.Id == session.CustomerId,
cancellationToken: cancellationToken);
}
if (!string.IsNullOrWhiteSpace(session.OpenId))
{
return await customerRepository.GetByWechatOpenidAsync(session.OpenId, cancellationToken);
}
return null;
}
/// <summary>
/// 刷新登录令牌。
/// </summary>
/// <param name="input">入参:刷新令牌。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>新的访问令牌、刷新令牌和用户基础信息。</returns>
[AllowAnonymous]
[HttpPost("/rbac/auth/refresh-token")]
public async Task<LoginResultDto> RefreshTokenAsync([FromBody] OmsRefreshTokenInput input, CancellationToken cancellationToken = default)
{
var refreshToken = input.RefreshToken;
var userIdText = jwtService.ValidateRefreshToken(refreshToken);
if (string.IsNullOrWhiteSpace(userIdText))
{
throw new BusinessException("无效的刷新令牌");
}
var token = new JwtSecurityTokenHandler().ReadJwtToken(refreshToken);
var loginType = ParseLoginType(token.Claims);
if (loginType == OmsLoginType.Customer)
{
return await RefreshCustomerTokenAsync(token.Claims, cancellationToken);
}
if (!Guid.TryParse(userIdText, out var userId))
{
throw new BusinessException("无效的刷新令牌");
}
var user = await userRepository.FirstOrDefaultAsync(x => x.Id == userId, cancellationToken: cancellationToken);
if (user == null)
{
throw new BusinessException("用户不存在");
}
var claims = await BuildAdminClaimsAsync(user.Id, cancellationToken);
return BuildLoginResult(user.Id, claims);
}
/// <summary>
/// 获取当前登录用户信息。
/// </summary>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>当前登录用户信息、角色和菜单权限。</returns>
[HttpGet("/rbac/auth/get-current-user")]
public async Task<UserInfoDto> GetCurrentUserAsync(CancellationToken cancellationToken = default)
{
if (!CurrentUser.IsAuthenticated)
{
throw new UnauthorizedAccessException("用户未登录");
}
if (IsCustomerPrincipal())
{
return await BuildAdminUserInfoAsync(CurrentUserGuid, cancellationToken);
}
return await BuildAdminUserInfoAsync(CurrentUserGuid, cancellationToken);
}
/// <summary>
/// 修改当前登录账号密码。
/// </summary>
/// <param name="input">入参:旧密码和新密码。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>无返回体,仅完成密码修改。</returns>
[HttpPost("/rbac/auth/change-password")]
public async Task ChangePasswordAsync([FromBody] OmsChangePasswordInput input, CancellationToken cancellationToken = default)
{
if (IsCustomerPrincipal())
{
await ChangeCustomerPasswordAsync(input.OldPassword, input.NewPassword, cancellationToken);
return;
}
await accountManager.UpdatePasswordAsync(CurrentUserGuid, input.OldPassword, input.NewPassword, cancellationToken);
}
/// <summary>
/// 更新当前管理员个人资料。
/// </summary>
/// <param name="input">入参:姓名和邮箱等个人资料。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>更新后的当前用户信息。</returns>
[HttpPut("/rbac/auth/update-profile")]
public async Task<UserInfoDto> UpdateProfileAsync([FromBody] OmsUpdateProfileInput input, CancellationToken cancellationToken = default)
{
EnsureAdminPrincipal();
var userId = CurrentUserGuid;
var user = await userRepository.FirstAsync(u => u.Id == userId, cancellationToken: cancellationToken);
user.Update(input.Name, input.Email);
await userRepository.UpdateAsync(user, cancellationToken: cancellationToken);
await UnitOfWorkManager.Current!.SaveChangesAsync(cancellationToken);
return await BuildAdminUserInfoAsync(userId, cancellationToken);
}
/// <summary>
/// 上传当前管理员头像。
/// </summary>
/// <param name="file">入参:头像文件。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>头像访问路径。</returns>
[HttpPost("/rbac/auth/upload-avatar")]
public async Task<string> UploadAvatarAsync(IFormFile file, CancellationToken cancellationToken = default)
{
EnsureAdminPrincipal();
var userId = CurrentUserGuid;
if (file == null || file.Length == 0)
{
throw new DomainException("请选择要上传的文件");
}
var allowedExtensions = new[] { ".jpg", ".jpeg", ".png", ".gif", ".webp" };
var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
if (!allowedExtensions.Contains(extension))
{
throw new DomainException("只支持 jpg、jpeg、png、gif、webp 格式的图片");
}
if (file.Length > 10 * 1024 * 1024)
{
throw new DomainException("文件大小不能超过10MB");
}
var uploadPath = Path.Combine(webHostEnvironment.WebRootPath ?? webHostEnvironment.ContentRootPath, "uploads", "avatars");
Directory.CreateDirectory(uploadPath);
var fileName = $"{userId}_{DateTime.Now:yyyyMMddHHmmss}{extension}";
var filePath = Path.Combine(uploadPath, fileName);
using (var stream = file.OpenReadStream())
using (var image = await Image.LoadAsync(stream, cancellationToken))
{
var maxSize = 200;
var width = image.Width;
var height = image.Height;
if (width > maxSize || height > maxSize)
{
if (width > height)
{
height = (int)(height * ((float)maxSize / width));
width = maxSize;
}
else
{
width = (int)(width * ((float)maxSize / height));
height = maxSize;
}
}
image.Mutate(x => x.Resize(width, height));
await image.SaveAsJpegAsync(filePath, new SixLabors.ImageSharp.Formats.Jpeg.JpegEncoder
{
Quality = 80
}, cancellationToken);
}
var avatarUrl = $"/uploads/avatars/{fileName}";
var user = await userRepository.FirstAsync(u => u.Id == userId, cancellationToken: cancellationToken);
user.UpdateAvatar(avatarUrl);
await userRepository.UpdateAsync(user, cancellationToken: cancellationToken);
await UnitOfWorkManager.Current!.SaveChangesAsync(cancellationToken);
return avatarUrl;
}
private async Task<LoginResultDto> LoginAdminAsync(string account, string password, CancellationToken cancellationToken)
{
var user = await accountManager.ValidateCredentialsAsync(account, password, cancellationToken);
var claims = await BuildAdminClaimsAsync(user.Id, cancellationToken);
return BuildLoginResult(user.Id, claims);
}
private async Task<LoginResultDto> LoginCustomerAsync(string account, string password, CancellationToken cancellationToken)
{
var normalizedAccount = account.Trim();
var customer = await FindCustomerByAccountAsync(normalizedAccount, cancellationToken);
if (customer == null)
{
throw new BusinessException("客户账号或密码错误");
}
if (customer.ParentCustomerId.HasValue)
{
ValidateSubCustomerStatus(customer);
var mainCustomer = await customerRepository.FirstOrDefaultAsync(x => x.Id == customer.ParentCustomerId.Value, cancellationToken);
if (mainCustomer == null)
{
throw new BusinessException("主客户不存在");
}
ValidateMainCustomerStatus(mainCustomer);
}
else
{
ValidateMainCustomerStatus(customer);
}
if (!string.Equals(customer.Password, OmsPasswordHelper.ToMd5(password), StringComparison.OrdinalIgnoreCase))
{
throw new BusinessException("客户账号或密码错误");
}
customer.LastLoginIp = httpContextAccessor.HttpContext?.Connection.RemoteIpAddress?.ToString();
customer.LastLoginTime = DateTime.Now;
await customerRepository.UpdateAsync(customer, cancellationToken);
var userGuid = await GetRbacUserGuidByMapIdAsync(customer.UserId, cancellationToken);
if (!userGuid.HasValue)
{
throw new BusinessException("客户未关联用户");
}
var claims = await BuildClaimsAsync(userGuid.Value, OmsLoginType.Customer, cancellationToken);
return BuildLoginResult(userGuid.Value, claims);
}
private async Task<LoginResultDto> RefreshCustomerTokenAsync(IEnumerable<Claim> claims, CancellationToken cancellationToken)
{
var mapId = ParseIntClaim(claims, SimpleOmsClaimTypes.MapId);
if (mapId <= 0)
{
throw new BusinessException("无效的刷新令牌");
}
var userGuid = await GetRbacUserGuidByMapIdAsync(mapId, cancellationToken);
if (!userGuid.HasValue)
{
throw new BusinessException("用户不存在");
}
var refreshedClaims = await BuildClaimsAsync(userGuid.Value, OmsLoginType.Customer, cancellationToken);
return BuildLoginResult(userGuid.Value, refreshedClaims);
}
private async Task<UserInfoDto> BuildAdminUserInfoAsync(Guid userId, CancellationToken cancellationToken)
{
var userInfo = await userService.GetAsync(userId, cancellationToken);
var permissions = await userService.GetUserPermissionsAsync(userId, cancellationToken);
return new UserInfoDto
{
Id = userInfo.Id,
Name = userInfo.Name,
Account = userInfo.Account,
Email = userInfo.Email,
Avatar = userInfo.Avatar,
OrganizationId = userInfo.OrganizationId,
OrganizationName = userInfo.OrganizationName,
Roles = userInfo.Roles,
Permissions = permissions,
Menus = await BuildMenusByPermissionsAsync(permissions, cancellationToken),
CreatedAt = userInfo.CreatedAt,
CreatedBy = userInfo.CreatedBy,
UpdatedAt = userInfo.UpdatedAt,
UpdatedBy = userInfo.UpdatedBy
};
}
private async Task ChangeCustomerPasswordAsync(string oldPassword, string newPassword, CancellationToken cancellationToken)
{
accountManager.ValidatePasswordStrength(newPassword);
var context = await GetCurrentCustomerContextAsync(cancellationToken);
if (!string.Equals(context.Customer.Password, OmsPasswordHelper.ToMd5(oldPassword), StringComparison.OrdinalIgnoreCase))
{
throw new BusinessException("旧密码不正确");
}
context.Customer.Password = OmsPasswordHelper.ToMd5(newPassword);
await customerRepository.UpdateAsync(context.Customer, cancellationToken);
}
private async Task<List<Claim>> BuildAdminClaimsAsync(Guid userId, CancellationToken cancellationToken)
{
return await BuildClaimsAsync(userId, OmsLoginType.User, cancellationToken);
}
private async Task<List<Claim>> BuildClaimsAsync(Guid userId, OmsLoginType loginType, CancellationToken cancellationToken)
{
var claims = await jwtService.GenerateClaimsByUserIdAsync(userId, cancellationToken);
ReplaceOrAppendClaim(claims, SimpleOmsClaimTypes.LoginType, ((int)loginType).ToString());
return claims;
}
private LoginResultDto BuildLoginResult(Guid userId, IReadOnlyCollection<Claim> claims)
{
var accessClaims = CloneClaims(claims);
var refreshClaims = CloneClaims(claims);
return new LoginResultDto
{
AccessToken = jwtService.GenerateAccessToken(accessClaims),
RefreshToken = jwtService.GenerateRefreshToken(refreshClaims),
TokenType = "Bearer",
ExpiresIn = jwtService.GetExpiresInSeconds(),
UserId = userId
};
}
private static List<Claim> CloneClaims(IEnumerable<Claim> claims)
{
return claims.Select(x => new Claim(x.Type, x.Value, x.ValueType, x.Issuer, x.OriginalIssuer)).ToList();
}
private bool IsCustomerPrincipal()
{
return CurrentUser.FindClaimOrNull(SimpleOmsClaimTypes.LoginType)?.Value == ((int)OmsLoginType.Customer).ToString();
}
private void EnsureAdminPrincipal()
{
if (IsCustomerPrincipal())
{
throw new BusinessException("当前登录主体暂不支持此操作");
}
}
private async Task<CustomerPrincipalContext> GetCurrentCustomerContextAsync(CancellationToken cancellationToken)
{
var userId = CurrentUser.GetId<int>();
if (userId <= 0)
{
throw new UnauthorizedAccessException("客户未登录");
}
var customer = await customerRepository.FirstOrDefaultAsync(x => x.UserId == userId, cancellationToken);
if (customer == null)
{
throw new BusinessException("客户不存在");
}
if (customer.ParentCustomerId.HasValue)
{
ValidateSubCustomerStatus(customer);
var mainCustomer = await customerRepository.FirstOrDefaultAsync(x => x.Id == customer.ParentCustomerId.Value, cancellationToken);
if (mainCustomer == null)
{
throw new BusinessException("主客户不存在");
}
ValidateMainCustomerStatus(mainCustomer);
}
else
{
ValidateMainCustomerStatus(customer);
}
return new CustomerPrincipalContext
{
PrincipalId = CurrentUserGuid,
Customer = customer,
DisplayName = customer.WechatNickname ?? customer.CustomerName,
AccountCode = customer.CustomerCode,
Email = customer.Email,
Avatar = customer.WechatAvatar,
CreateTime = customer.CreateTime,
UpdateTime = customer.UpdateTime
};
}
private async Task<Customer?> FindCustomerByAccountAsync(string account, CancellationToken cancellationToken)
{
var customer = await customerRepository.GetByPhoneAsync(account, cancellationToken);
if (customer != null)
{
return customer;
}
customer = await customerRepository.GetByEmailAsync(account, cancellationToken);
if (customer != null)
{
return customer;
}
return await customerRepository.GetByCustomerCodeAsync(account, cancellationToken);
}
private async Task<List<MenuTreeDto>> BuildMenusByPermissionsAsync(IReadOnlyCollection<string> permissionCodes, CancellationToken cancellationToken)
{
if (permissionCodes.Count == 0)
{
return [];
}
var allMenus = await rbacDbContext.Menus
.Where(x => !x.IsDeleted)
.OrderBy(x => x.Order)
.ToListAsync(cancellationToken);
var menuMap = allMenus.ToDictionary(x => x.Id);
var selectedMenuIds = new HashSet<Guid>(allMenus
.Where(x => !string.IsNullOrWhiteSpace(x.PermissionCode) && permissionCodes.Contains(x.PermissionCode, StringComparer.OrdinalIgnoreCase))
.Select(x => x.Id));
var queue = new Queue<Guid>(selectedMenuIds);
while (queue.Count > 0)
{
var menuId = queue.Dequeue();
if (!menuMap.TryGetValue(menuId, out var currentMenu))
{
continue;
}
if (currentMenu.PId != Guid.Empty && selectedMenuIds.Add(currentMenu.PId))
{
queue.Enqueue(currentMenu.PId);
}
}
var menuDtos = allMenus
.Where(x => selectedMenuIds.Contains(x.Id))
.OrderBy(x => x.Order)
.ThenBy(x => x.CreateTime)
.Select(x => new MenuTreeDto
{
Id = x.Id,
PId = x.PId,
Name = x.Name,
PermissionCode = x.PermissionCode,
Type = x.Type,
Icon = x.Icon,
Route = x.Route,
Component = x.Component,
Order = x.Order,
IsHidden = x.IsHidden,
IsCached = x.IsCached,
Description = x.Description,
CreatedAt = x.CreateTime,
UpdatedAt = x.UpdateTime == default ? null : x.UpdateTime
})
.ToList();
var dtoMap = menuDtos.ToDictionary(x => x.Id);
var roots = new List<MenuTreeDto>();
foreach (var menu in menuDtos)
{
if (menu.PId == Guid.Empty || !dtoMap.TryGetValue(menu.PId, out var parentMenu))
{
roots.Add(menu);
continue;
}
parentMenu.Children.Add(menu);
}
return roots.OrderBy(x => x.Order).ToList();
}
private static void ValidateMainCustomerStatus(Customer customer)
{
if (customer.Status == CustomerStatus.Disabled)
{
throw new BusinessException("客户已禁用");
}
if (customer.Status == CustomerStatus.Frozen)
{
throw new BusinessException("客户已冻结");
}
}
private static void ValidateSubCustomerStatus(Customer customer)
{
if (customer.Status == CustomerStatus.Disabled)
{
throw new BusinessException("子客户已禁用");
}
if (customer.Status == CustomerStatus.Frozen)
{
throw new BusinessException("子客户已冻结");
}
}
private static OmsLoginType ParseLoginType(IEnumerable<Claim> claims)
{
return int.TryParse(claims.FirstOrDefault(x => x.Type == SimpleOmsClaimTypes.LoginType)?.Value, out var loginType) &&
Enum.IsDefined(typeof(OmsLoginType), loginType)
? (OmsLoginType)loginType
: OmsLoginType.User;
}
private async Task<Guid?> GetRbacUserGuidByMapIdAsync(int userId, CancellationToken cancellationToken)
{
return await rbacDbContext.Database.SqlQuery<Guid?>(
$"select `Id` as `Value` from `user` where `IsDeleted` = 0 and `userid` = {userId}")
.FirstOrDefaultAsync(cancellationToken);
}
private static int ParseIntClaim(IEnumerable<Claim> claims, string claimType)
{
return int.TryParse(claims.FirstOrDefault(x => x.Type == claimType)?.Value, out var value) ? value : 0;
}
private static void ReplaceOrAppendClaim(List<Claim> claims, string claimType, string value)
{
claims.RemoveAll(x => x.Type == claimType);
claims.Add(new Claim(claimType, value));
}
private sealed class CustomerPrincipalContext
{
public Guid PrincipalId { get; set; }
public required Customer Customer { get; init; }
public required string DisplayName { get; init; }
public required string AccountCode { get; init; }
public string? Email { get; init; }
public string? Avatar { get; init; }
public DateTime CreateTime { get; init; }
public DateTime UpdateTime { get; init; }
}
}