feat: 完成租户个人中心 API 首版实现
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Personal.Dto;
|
||||
using TakeoutSaaS.Application.App.Personal.Queries;
|
||||
using TakeoutSaaS.Application.App.Personal.Services;
|
||||
using TakeoutSaaS.Domain.Identity.Enums;
|
||||
using TakeoutSaaS.Domain.Identity.Repositories;
|
||||
using TakeoutSaaS.Domain.Merchants.Repositories;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Personal.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 获取个人中心总览处理器。
|
||||
/// </summary>
|
||||
public sealed class GetPersonalOverviewQueryHandler(
|
||||
PersonalContextService personalContextService,
|
||||
PersonalMaskingService personalMaskingService,
|
||||
PersonalModuleStatusService moduleStatusService,
|
||||
IIdentityUserRepository identityUserRepository,
|
||||
ITenantRepository tenantRepository,
|
||||
ITenantPackageRepository tenantPackageRepository,
|
||||
IMerchantRepository merchantRepository,
|
||||
IMediator mediator)
|
||||
: IRequestHandler<GetPersonalOverviewQuery, PersonalOverviewDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 处理查询。
|
||||
/// </summary>
|
||||
/// <param name="request">查询请求。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>总览结果。</returns>
|
||||
public async Task<PersonalOverviewDto> Handle(GetPersonalOverviewQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 获取必需上下文
|
||||
var context = personalContextService.GetRequiredContext();
|
||||
var traceId = context.TraceId;
|
||||
|
||||
// 2. 初始化总览容器
|
||||
var moduleStatuses = new List<PersonalModuleStatusDto>();
|
||||
PersonalAccountProfileDto? accountProfile = null;
|
||||
PersonalSecuritySnapshotDto? securitySnapshot = null;
|
||||
PersonalRolePermissionSummaryDto? roleSummary = null;
|
||||
PersonalTenantAffiliationDto? tenantAffiliation = null;
|
||||
|
||||
// 3. 加载账号与安全模块
|
||||
var user = await identityUserRepository.FindByIdAsync(context.UserId, cancellationToken)
|
||||
?? throw new BusinessException(ErrorCodes.NotFound, "用户不存在");
|
||||
if (user.TenantId != context.TenantId)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "无权访问其他租户用户数据");
|
||||
}
|
||||
|
||||
accountProfile = new PersonalAccountProfileDto
|
||||
{
|
||||
UserId = user.Id,
|
||||
Account = user.Account,
|
||||
DisplayName = user.DisplayName,
|
||||
AvatarUrl = user.Avatar,
|
||||
PhoneMasked = personalMaskingService.MaskPhone(user.Phone),
|
||||
EmailMasked = personalMaskingService.MaskEmail(user.Email),
|
||||
RegisteredAt = user.CreatedAt
|
||||
};
|
||||
securitySnapshot = new PersonalSecuritySnapshotDto
|
||||
{
|
||||
LastLoginAt = user.LastLoginAt,
|
||||
FailedLoginCount = user.FailedLoginCount,
|
||||
IsLocked = user.Status == IdentityUserStatus.Locked || (user.LockedUntil.HasValue && user.LockedUntil > DateTime.UtcNow),
|
||||
LockedUntil = user.LockedUntil,
|
||||
IsForceChangePassword = user.MustChangePassword
|
||||
};
|
||||
moduleStatuses.Add(moduleStatusService.BuildOk("accountSecurity", traceId));
|
||||
|
||||
// 4. 加载角色权限模块(失败可降级)
|
||||
try
|
||||
{
|
||||
roleSummary = await mediator.Send(new GetPersonalRolesQuery(), cancellationToken);
|
||||
moduleStatuses.Add(moduleStatusService.BuildOk("roleSummary", traceId));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
moduleStatuses.Add(moduleStatusService.BuildIssue("roleSummary", "degraded", ErrorCodes.InternalServerError.ToString(), ex.Message, traceId));
|
||||
}
|
||||
|
||||
// 5. 加载租户归属模块(失败可降级)
|
||||
try
|
||||
{
|
||||
var tenant = await tenantRepository.FindByIdAsync(context.TenantId, cancellationToken)
|
||||
?? throw new BusinessException(ErrorCodes.NotFound, "租户不存在");
|
||||
var subscription = await tenantRepository.GetActiveSubscriptionAsync(context.TenantId, cancellationToken);
|
||||
|
||||
string? packageName = null;
|
||||
if (subscription is not null)
|
||||
{
|
||||
var package = await tenantPackageRepository.FindByIdAsync(subscription.TenantPackageId, cancellationToken);
|
||||
packageName = package?.Name;
|
||||
}
|
||||
|
||||
string? merchantName = null;
|
||||
string merchantStatus = "unknown";
|
||||
if (user.MerchantId is > 0)
|
||||
{
|
||||
var merchant = await merchantRepository.FindByIdAsync(user.MerchantId.Value, context.TenantId, cancellationToken);
|
||||
merchantName = merchant?.BrandName;
|
||||
merchantStatus = merchant?.Status.ToString().ToLowerInvariant() ?? "unknown";
|
||||
}
|
||||
|
||||
tenantAffiliation = new PersonalTenantAffiliationDto
|
||||
{
|
||||
TenantId = tenant.Id,
|
||||
TenantName = tenant.Name,
|
||||
MerchantId = user.MerchantId,
|
||||
MerchantName = merchantName,
|
||||
MerchantStatus = merchantStatus,
|
||||
PackageName = packageName,
|
||||
SubscriptionExpireAt = subscription?.EffectiveTo ?? tenant.EffectiveTo
|
||||
};
|
||||
moduleStatuses.Add(moduleStatusService.BuildOk("tenantAffiliation", traceId));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
moduleStatuses.Add(moduleStatusService.BuildIssue("tenantAffiliation", "degraded", ErrorCodes.InternalServerError.ToString(), ex.Message, traceId));
|
||||
}
|
||||
|
||||
// 6. 计算总体状态并返回
|
||||
var hasAnyData = accountProfile is not null
|
||||
|| securitySnapshot is not null
|
||||
|| roleSummary is not null
|
||||
|| tenantAffiliation is not null;
|
||||
var requestId = string.IsNullOrWhiteSpace(traceId) ? Guid.NewGuid().ToString("N") : traceId;
|
||||
var overallStatus = moduleStatusService.ResolveOverallStatus(moduleStatuses, hasAnyData);
|
||||
return new PersonalOverviewDto
|
||||
{
|
||||
RequestId = requestId,
|
||||
OverallStatus = overallStatus,
|
||||
AccountProfile = accountProfile,
|
||||
SecuritySnapshot = securitySnapshot,
|
||||
RoleSummary = roleSummary,
|
||||
TenantAffiliation = tenantAffiliation,
|
||||
QuotaSummary = null,
|
||||
ModuleStatuses = moduleStatuses
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Personal.Dto;
|
||||
using TakeoutSaaS.Application.App.Personal.Queries;
|
||||
using TakeoutSaaS.Application.App.Personal.Services;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Personal.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 获取个人中心配额摘要处理器。
|
||||
/// </summary>
|
||||
public sealed class GetPersonalQuotaQueryHandler(
|
||||
PersonalContextService personalContextService,
|
||||
PersonalAuditService personalAuditService,
|
||||
ITenantQuotaUsageRepository tenantQuotaUsageRepository,
|
||||
ITenantVisibilityRoleRuleRepository visibilityRoleRuleRepository)
|
||||
: IRequestHandler<GetPersonalQuotaQuery, PersonalQuotaUsageSummaryDto>
|
||||
{
|
||||
private static readonly string[] DefaultQuotaVisibleRoles = ["tenant-owner", "tenant-admin"];
|
||||
|
||||
/// <summary>
|
||||
/// 处理查询。
|
||||
/// </summary>
|
||||
/// <param name="request">查询请求。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>配额摘要。</returns>
|
||||
public async Task<PersonalQuotaUsageSummaryDto> Handle(GetPersonalQuotaQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 1. 获取请求上下文
|
||||
var context = personalContextService.GetRequiredContext();
|
||||
|
||||
// 2. 校验可见角色权限
|
||||
var rule = await visibilityRoleRuleRepository.FindByTenantIdAsync(context.TenantId, cancellationToken);
|
||||
var allowedRoles = rule?.QuotaVisibleRoleCodes?.Length > 0
|
||||
? rule.QuotaVisibleRoleCodes
|
||||
: DefaultQuotaVisibleRoles;
|
||||
if (!HasAnyRole(context.RoleCodes, allowedRoles))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "当前角色无权访问配额数据");
|
||||
}
|
||||
|
||||
// 3. 查询配额并组装摘要
|
||||
var usages = await tenantQuotaUsageRepository.GetByTenantAsync(context.TenantId, cancellationToken);
|
||||
var items = usages
|
||||
.OrderBy(x => x.QuotaType)
|
||||
.Select(MapQuotaItem)
|
||||
.ToList();
|
||||
|
||||
// 4. 记录审计并返回
|
||||
await personalAuditService.RecordSensitiveQueryAsync("quota", true, "查询配额摘要成功", cancellationToken);
|
||||
return new PersonalQuotaUsageSummaryDto
|
||||
{
|
||||
Items = items
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 5. 记录失败审计并继续抛出
|
||||
await personalAuditService.RecordSensitiveQueryAsync("quota", false, ex.Message, cancellationToken);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static PersonalQuotaUsageItemDto MapQuotaItem(TakeoutSaaS.Domain.Tenants.Entities.TenantQuotaUsage usage)
|
||||
{
|
||||
// 1. 映射元数据
|
||||
var metadata = usage.QuotaType switch
|
||||
{
|
||||
TenantQuotaType.StoreCount => ("store_count", "门店数量", "个"),
|
||||
TenantQuotaType.AccountCount => ("account_count", "账号数量", "个"),
|
||||
TenantQuotaType.Storage => ("storage", "存储空间", "GB"),
|
||||
TenantQuotaType.SmsCredits => ("sms_credits", "短信额度", "条"),
|
||||
TenantQuotaType.DeliveryOrders => ("delivery_orders", "配送订单", "单"),
|
||||
TenantQuotaType.PromotionSlots => ("promotion_slots", "营销位", "个"),
|
||||
_ => (usage.QuotaType.ToString().ToLowerInvariant(), usage.QuotaType.ToString(), string.Empty)
|
||||
};
|
||||
|
||||
// 2. 计算使用率
|
||||
var ratio = usage.LimitValue <= 0 ? 0 : Math.Round(usage.UsedValue / usage.LimitValue, 4);
|
||||
|
||||
// 3. 返回 DTO
|
||||
return new PersonalQuotaUsageItemDto
|
||||
{
|
||||
QuotaCode = metadata.Item1,
|
||||
QuotaName = metadata.Item2,
|
||||
LimitValue = usage.LimitValue,
|
||||
UsedValue = usage.UsedValue,
|
||||
Unit = metadata.Item3,
|
||||
UsageRatio = ratio
|
||||
};
|
||||
}
|
||||
|
||||
private static bool HasAnyRole(IReadOnlyList<string> currentRoles, IReadOnlyList<string> allowedRoles)
|
||||
{
|
||||
// 1. 构建角色集合并执行交集判断
|
||||
var current = currentRoles
|
||||
.Where(static x => !string.IsNullOrWhiteSpace(x))
|
||||
.Select(static x => x.Trim())
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// 2. 返回是否命中任一允许角色
|
||||
return allowedRoles.Any(current.Contains);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Personal.Dto;
|
||||
using TakeoutSaaS.Application.App.Personal.Queries;
|
||||
using TakeoutSaaS.Application.App.Personal.Services;
|
||||
using TakeoutSaaS.Domain.Identity.Repositories;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Personal.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 获取个人角色与权限概览处理器。
|
||||
/// </summary>
|
||||
public sealed class GetPersonalRolesQueryHandler(
|
||||
PersonalContextService personalContextService,
|
||||
IUserRoleRepository userRoleRepository,
|
||||
IRoleRepository roleRepository,
|
||||
IRolePermissionRepository rolePermissionRepository)
|
||||
: IRequestHandler<GetPersonalRolesQuery, PersonalRolePermissionSummaryDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 处理查询。
|
||||
/// </summary>
|
||||
/// <param name="request">查询请求。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>角色权限概览。</returns>
|
||||
public async Task<PersonalRolePermissionSummaryDto> Handle(GetPersonalRolesQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 获取请求上下文
|
||||
var context = personalContextService.GetRequiredContext();
|
||||
|
||||
// 2. 获取用户角色关系
|
||||
var userRoles = await userRoleRepository.GetByUserIdAsync(context.TenantId, context.UserId, cancellationToken);
|
||||
var roleIds = userRoles.Select(static x => x.RoleId).Distinct().ToArray();
|
||||
if (roleIds.Length == 0)
|
||||
{
|
||||
return new PersonalRolePermissionSummaryDto
|
||||
{
|
||||
Roles = [],
|
||||
PermissionCount = 0
|
||||
};
|
||||
}
|
||||
|
||||
// 3. 获取角色明细
|
||||
var roles = await roleRepository.GetByIdsAsync(context.TenantId, roleIds, cancellationToken);
|
||||
var roleItems = roles
|
||||
.OrderBy(static x => x.Name)
|
||||
.Select(static x => new PersonalRoleItemDto
|
||||
{
|
||||
RoleCode = x.Code,
|
||||
RoleName = x.Name
|
||||
})
|
||||
.ToList();
|
||||
|
||||
// 4. 统计去重权限数量
|
||||
var rolePermissions = await rolePermissionRepository.GetByRoleIdsAsync(context.TenantId, roleIds, cancellationToken);
|
||||
var permissionCount = rolePermissions
|
||||
.Select(static x => x.PermissionId)
|
||||
.Distinct()
|
||||
.Count();
|
||||
|
||||
// 5. 返回结果
|
||||
return new PersonalRolePermissionSummaryDto
|
||||
{
|
||||
Roles = roleItems,
|
||||
PermissionCount = permissionCount
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Personal.Dto;
|
||||
using TakeoutSaaS.Application.App.Personal.Queries;
|
||||
using TakeoutSaaS.Application.App.Personal.Services;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Personal.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 获取个人中心可见角色配置处理器。
|
||||
/// </summary>
|
||||
public sealed class GetPersonalVisibilityRoleConfigQueryHandler(
|
||||
PersonalContextService personalContextService,
|
||||
ITenantVisibilityRoleRuleRepository visibilityRoleRuleRepository)
|
||||
: IRequestHandler<GetPersonalVisibilityRoleConfigQuery, PersonalVisibilityRoleConfigDto>
|
||||
{
|
||||
private static readonly string[] ManagerRoleCodes = ["tenant-owner", "tenant-admin"];
|
||||
private static readonly string[] DefaultVisibleRoleCodes = ["tenant-owner", "tenant-admin"];
|
||||
|
||||
/// <summary>
|
||||
/// 处理查询。
|
||||
/// </summary>
|
||||
/// <param name="request">查询请求。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>可见角色配置。</returns>
|
||||
public async Task<PersonalVisibilityRoleConfigDto> Handle(GetPersonalVisibilityRoleConfigQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 获取上下文并校验管理权限
|
||||
var context = personalContextService.GetRequiredContext();
|
||||
if (!HasAnyRole(context.RoleCodes, ManagerRoleCodes))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "当前角色无权查看可见角色配置");
|
||||
}
|
||||
|
||||
// 2. 查询并返回规则
|
||||
var rule = await visibilityRoleRuleRepository.FindByTenantIdAsync(context.TenantId, cancellationToken);
|
||||
if (rule is null)
|
||||
{
|
||||
return new PersonalVisibilityRoleConfigDto
|
||||
{
|
||||
TenantId = context.TenantId,
|
||||
QuotaVisibleRoleCodes = DefaultVisibleRoleCodes,
|
||||
BillingVisibleRoleCodes = DefaultVisibleRoleCodes,
|
||||
UpdatedBy = context.UserId,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
return new PersonalVisibilityRoleConfigDto
|
||||
{
|
||||
TenantId = rule.TenantId,
|
||||
QuotaVisibleRoleCodes = rule.QuotaVisibleRoleCodes,
|
||||
BillingVisibleRoleCodes = rule.BillingVisibleRoleCodes,
|
||||
UpdatedBy = rule.UpdatedBy ?? context.UserId,
|
||||
UpdatedAt = rule.UpdatedAt ?? DateTime.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
private static bool HasAnyRole(IReadOnlyList<string> currentRoles, IReadOnlyList<string> allowedRoles)
|
||||
{
|
||||
// 1. 归一化当前角色
|
||||
var roleSet = currentRoles
|
||||
.Where(static x => !string.IsNullOrWhiteSpace(x))
|
||||
.Select(static x => x.Trim())
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// 2. 判断是否命中允许角色
|
||||
return allowedRoles.Any(roleSet.Contains);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Personal.Dto;
|
||||
using TakeoutSaaS.Application.App.Personal.Queries;
|
||||
using TakeoutSaaS.Application.App.Personal.Services;
|
||||
using TakeoutSaaS.Application.App.Personal.Validators;
|
||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Personal.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 分页查询个人中心账单记录处理器。
|
||||
/// </summary>
|
||||
public sealed class SearchPersonalBillingStatementsQueryHandler(
|
||||
PersonalContextService personalContextService,
|
||||
PersonalDateRangeValidator personalDateRangeValidator,
|
||||
PersonalAuditService personalAuditService,
|
||||
ITenantBillingRepository tenantBillingRepository,
|
||||
ITenantVisibilityRoleRuleRepository visibilityRoleRuleRepository)
|
||||
: IRequestHandler<SearchPersonalBillingStatementsQuery, PagedResult<PersonalBillingStatementDto>>
|
||||
{
|
||||
private static readonly string[] DefaultBillingVisibleRoles = ["tenant-owner", "tenant-admin"];
|
||||
|
||||
/// <summary>
|
||||
/// 处理查询。
|
||||
/// </summary>
|
||||
/// <param name="request">查询请求。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>账单分页结果。</returns>
|
||||
public async Task<PagedResult<PersonalBillingStatementDto>> Handle(SearchPersonalBillingStatementsQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 1. 获取上下文并校验可见角色
|
||||
var context = personalContextService.GetRequiredContext();
|
||||
var rule = await visibilityRoleRuleRepository.FindByTenantIdAsync(context.TenantId, cancellationToken);
|
||||
var allowedRoles = rule?.BillingVisibleRoleCodes?.Length > 0
|
||||
? rule.BillingVisibleRoleCodes
|
||||
: DefaultBillingVisibleRoles;
|
||||
if (!HasAnyRole(context.RoleCodes, allowedRoles))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "当前角色无权访问账单数据");
|
||||
}
|
||||
|
||||
// 2. 解析时间窗与分页参数
|
||||
var dateRange = personalDateRangeValidator.Resolve(request.From, request.To);
|
||||
var page = request.Page <= 0 ? 1 : request.Page;
|
||||
var pageSize = request.PageSize <= 0 ? 20 : Math.Min(request.PageSize, 50);
|
||||
|
||||
// 3. 查询并分页
|
||||
var statements = await tenantBillingRepository.SearchAsync(context.TenantId, null, dateRange.From, dateRange.To, cancellationToken);
|
||||
var ordered = statements.OrderByDescending(static x => x.PeriodEnd).ToList();
|
||||
var items = ordered
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(MapBilling)
|
||||
.ToList();
|
||||
|
||||
// 4. 记录审计并返回
|
||||
await personalAuditService.RecordSensitiveQueryAsync("billing-statements", true, "查询账单成功", cancellationToken);
|
||||
return new PagedResult<PersonalBillingStatementDto>(items, page, pageSize, ordered.Count);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 5. 记录失败审计并继续抛出
|
||||
await personalAuditService.RecordSensitiveQueryAsync("billing-statements", false, ex.Message, cancellationToken);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static PersonalBillingStatementDto MapBilling(TenantBillingStatement billing)
|
||||
{
|
||||
// 1. 映射账单状态
|
||||
var status = billing.Status switch
|
||||
{
|
||||
TenantBillingStatus.Pending when billing.AmountPaid > 0 => "partial_paid",
|
||||
_ => billing.Status.ToString().ToLowerInvariant()
|
||||
};
|
||||
|
||||
// 2. 返回 DTO
|
||||
return new PersonalBillingStatementDto
|
||||
{
|
||||
StatementId = billing.Id,
|
||||
BillingPeriodStart = DateOnly.FromDateTime(billing.PeriodStart),
|
||||
BillingPeriodEnd = DateOnly.FromDateTime(billing.PeriodEnd),
|
||||
AmountDue = billing.AmountDue,
|
||||
AmountPaid = billing.AmountPaid,
|
||||
Status = status,
|
||||
DueAt = billing.DueDate
|
||||
};
|
||||
}
|
||||
|
||||
private static bool HasAnyRole(IReadOnlyList<string> currentRoles, IReadOnlyList<string> allowedRoles)
|
||||
{
|
||||
// 1. 归一化当前角色
|
||||
var roleSet = currentRoles
|
||||
.Where(static x => !string.IsNullOrWhiteSpace(x))
|
||||
.Select(static x => x.Trim())
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// 2. 命中任一允许角色即通过
|
||||
return allowedRoles.Any(roleSet.Contains);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Personal.Dto;
|
||||
using TakeoutSaaS.Application.App.Personal.Queries;
|
||||
using TakeoutSaaS.Application.App.Personal.Services;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Personal.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 分页查询个人消息摘要处理器。
|
||||
/// </summary>
|
||||
public sealed class SearchPersonalNotificationsQueryHandler(
|
||||
PersonalContextService personalContextService,
|
||||
ITenantNotificationRepository tenantNotificationRepository)
|
||||
: IRequestHandler<SearchPersonalNotificationsQuery, PagedResult<PersonalNotificationDto>>
|
||||
{
|
||||
/// <summary>
|
||||
/// 处理查询。
|
||||
/// </summary>
|
||||
/// <param name="request">查询请求。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>消息摘要分页结果。</returns>
|
||||
public async Task<PagedResult<PersonalNotificationDto>> Handle(SearchPersonalNotificationsQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 获取上下文并解析分页
|
||||
var context = personalContextService.GetRequiredContext();
|
||||
var page = request.Page <= 0 ? 1 : request.Page;
|
||||
var pageSize = request.PageSize <= 0 ? 20 : Math.Min(request.PageSize, 50);
|
||||
|
||||
// 2. 查询通知
|
||||
var notifications = await tenantNotificationRepository.SearchAsync(
|
||||
context.TenantId,
|
||||
severity: null,
|
||||
unreadOnly: request.UnreadOnly,
|
||||
from: null,
|
||||
to: null,
|
||||
cancellationToken);
|
||||
|
||||
// 3. 排序分页并映射
|
||||
var ordered = notifications.OrderByDescending(static x => x.SentAt).ToList();
|
||||
var items = ordered
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(static x => new PersonalNotificationDto
|
||||
{
|
||||
NotificationId = x.Id,
|
||||
Title = x.Title,
|
||||
Category = x.Severity.ToString().ToLowerInvariant(),
|
||||
IsRead = x.ReadAt.HasValue,
|
||||
SentAt = x.SentAt
|
||||
})
|
||||
.ToList();
|
||||
|
||||
// 4. 返回结果
|
||||
return new PagedResult<PersonalNotificationDto>(items, page, pageSize, ordered.Count);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
using MediatR;
|
||||
using System.Text.Json;
|
||||
using TakeoutSaaS.Application.App.Personal.Dto;
|
||||
using TakeoutSaaS.Application.App.Personal.Queries;
|
||||
using TakeoutSaaS.Application.App.Personal.Services;
|
||||
using TakeoutSaaS.Application.App.Personal.Validators;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Personal.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 分页查询个人操作记录处理器。
|
||||
/// </summary>
|
||||
public sealed class SearchPersonalOperationsQueryHandler(
|
||||
PersonalContextService personalContextService,
|
||||
PersonalDateRangeValidator personalDateRangeValidator,
|
||||
IOperationLogRepository operationLogRepository)
|
||||
: IRequestHandler<SearchPersonalOperationsQuery, PagedResult<PersonalOperationLogDto>>
|
||||
{
|
||||
/// <summary>
|
||||
/// 处理查询。
|
||||
/// </summary>
|
||||
/// <param name="request">查询请求。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>操作记录分页结果。</returns>
|
||||
public async Task<PagedResult<PersonalOperationLogDto>> Handle(SearchPersonalOperationsQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 获取上下文并解析时间窗
|
||||
var context = personalContextService.GetRequiredContext();
|
||||
var dateRange = personalDateRangeValidator.Resolve(request.From, request.To);
|
||||
|
||||
// 2. 解析分页参数(默认 50,上限 50)
|
||||
var page = request.Page <= 0 ? 1 : request.Page;
|
||||
var pageSize = request.PageSize <= 0 ? 50 : Math.Min(request.PageSize, 50);
|
||||
|
||||
// 3. 查询并映射
|
||||
var paged = await operationLogRepository.SearchByOperatorPagedAsync(
|
||||
context.TenantId,
|
||||
context.UserId.ToString(),
|
||||
dateRange.From,
|
||||
dateRange.To,
|
||||
page,
|
||||
pageSize,
|
||||
cancellationToken);
|
||||
var items = paged.Items.Select(log => new PersonalOperationLogDto
|
||||
{
|
||||
OperationId = log.Id,
|
||||
OperatorUserId = ParseOperatorUserId(log.OperatorId, context.UserId),
|
||||
ActionType = log.OperationType,
|
||||
TargetType = log.TargetType,
|
||||
TargetId = ResolveTargetId(log.TargetIds),
|
||||
IsSuccess = log.Success,
|
||||
OccurredAt = log.CreatedAt
|
||||
}).ToList();
|
||||
|
||||
// 4. 返回分页结果
|
||||
return new PagedResult<PersonalOperationLogDto>(items, page, pageSize, paged.Total);
|
||||
}
|
||||
|
||||
private static long ParseOperatorUserId(string? operatorId, long fallbackUserId)
|
||||
{
|
||||
// 1. 尝试从字符串解析用户 ID
|
||||
if (long.TryParse(operatorId, out var parsed) && parsed > 0)
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
|
||||
// 2. 解析失败时回退当前用户
|
||||
return fallbackUserId;
|
||||
}
|
||||
|
||||
private static string? ResolveTargetId(string? targetIds)
|
||||
{
|
||||
// 1. 空值直接返回
|
||||
if (string.IsNullOrWhiteSpace(targetIds))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// 2. 尝试解析 JSON 数组并提取首个元素
|
||||
var normalized = targetIds.Trim();
|
||||
if (normalized.StartsWith("[", StringComparison.Ordinal))
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(normalized);
|
||||
if (doc.RootElement.ValueKind == JsonValueKind.Array && doc.RootElement.GetArrayLength() > 0)
|
||||
{
|
||||
return doc.RootElement[0].ToString();
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// 3. JSON 解析失败时按原始字符串返回
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 非数组场景直接返回原值
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Personal.Dto;
|
||||
using TakeoutSaaS.Application.App.Personal.Queries;
|
||||
using TakeoutSaaS.Application.App.Personal.Services;
|
||||
using TakeoutSaaS.Application.App.Personal.Validators;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Personal.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 分页查询个人中心支付记录处理器。
|
||||
/// </summary>
|
||||
public sealed class SearchPersonalPaymentsQueryHandler(
|
||||
PersonalContextService personalContextService,
|
||||
PersonalDateRangeValidator personalDateRangeValidator,
|
||||
PersonalAuditService personalAuditService,
|
||||
ITenantPaymentRepository tenantPaymentRepository,
|
||||
ITenantVisibilityRoleRuleRepository visibilityRoleRuleRepository)
|
||||
: IRequestHandler<SearchPersonalPaymentsQuery, PagedResult<PersonalPaymentRecordDto>>
|
||||
{
|
||||
private static readonly string[] DefaultBillingVisibleRoles = ["tenant-owner", "tenant-admin"];
|
||||
|
||||
/// <summary>
|
||||
/// 处理查询。
|
||||
/// </summary>
|
||||
/// <param name="request">查询请求。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>支付记录分页结果。</returns>
|
||||
public async Task<PagedResult<PersonalPaymentRecordDto>> Handle(SearchPersonalPaymentsQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 1. 获取上下文并校验角色可见性
|
||||
var context = personalContextService.GetRequiredContext();
|
||||
var rule = await visibilityRoleRuleRepository.FindByTenantIdAsync(context.TenantId, cancellationToken);
|
||||
var allowedRoles = rule?.BillingVisibleRoleCodes?.Length > 0
|
||||
? rule.BillingVisibleRoleCodes
|
||||
: DefaultBillingVisibleRoles;
|
||||
if (!HasAnyRole(context.RoleCodes, allowedRoles))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "当前角色无权访问支付数据");
|
||||
}
|
||||
|
||||
// 2. 解析时间窗和分页参数
|
||||
var dateRange = personalDateRangeValidator.Resolve(request.From, request.To);
|
||||
var page = request.Page <= 0 ? 1 : request.Page;
|
||||
var pageSize = request.PageSize <= 0 ? 20 : Math.Min(request.PageSize, 50);
|
||||
|
||||
// 3. 查询分页数据
|
||||
var paged = await tenantPaymentRepository.SearchPagedAsync(
|
||||
context.TenantId,
|
||||
dateRange.From,
|
||||
dateRange.To,
|
||||
page,
|
||||
pageSize,
|
||||
cancellationToken);
|
||||
var items = paged.Items.Select(MapPayment).ToList();
|
||||
|
||||
// 4. 记录审计并返回
|
||||
await personalAuditService.RecordSensitiveQueryAsync("billing-payments", true, "查询支付记录成功", cancellationToken);
|
||||
return new PagedResult<PersonalPaymentRecordDto>(items, page, pageSize, paged.Total);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 5. 记录失败审计并继续抛出
|
||||
await personalAuditService.RecordSensitiveQueryAsync("billing-payments", false, ex.Message, cancellationToken);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static PersonalPaymentRecordDto MapPayment(Domain.Tenants.Entities.TenantPayment payment)
|
||||
{
|
||||
// 1. 映射支付方式与状态
|
||||
var method = payment.Method switch
|
||||
{
|
||||
TenantPaymentMethod.Online => "online",
|
||||
TenantPaymentMethod.BankTransfer => "bank_transfer",
|
||||
TenantPaymentMethod.Other => "other",
|
||||
_ => payment.Method.ToString().ToLowerInvariant()
|
||||
};
|
||||
var status = payment.Status switch
|
||||
{
|
||||
TenantPaymentStatus.Pending => "pending",
|
||||
TenantPaymentStatus.Success => "success",
|
||||
TenantPaymentStatus.Failed => "failed",
|
||||
TenantPaymentStatus.Refunded => "refunded",
|
||||
_ => payment.Status.ToString().ToLowerInvariant()
|
||||
};
|
||||
|
||||
// 2. 返回 DTO
|
||||
return new PersonalPaymentRecordDto
|
||||
{
|
||||
PaymentId = payment.Id,
|
||||
StatementId = payment.BillingStatementId,
|
||||
PaidAmount = payment.Amount,
|
||||
PaymentMethod = method,
|
||||
PaymentStatus = status,
|
||||
PaidAt = payment.PaidAt
|
||||
};
|
||||
}
|
||||
|
||||
private static bool HasAnyRole(IReadOnlyList<string> currentRoles, IReadOnlyList<string> allowedRoles)
|
||||
{
|
||||
// 1. 构建当前角色集合
|
||||
var roleSet = currentRoles
|
||||
.Where(static x => !string.IsNullOrWhiteSpace(x))
|
||||
.Select(static x => x.Trim())
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// 2. 判断是否命中允许角色
|
||||
return allowedRoles.Any(roleSet.Contains);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Personal.Dto;
|
||||
using TakeoutSaaS.Application.App.Personal.Queries;
|
||||
using TakeoutSaaS.Application.App.Personal.Services;
|
||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Personal.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 更新个人中心可见角色配置处理器。
|
||||
/// </summary>
|
||||
public sealed class UpdatePersonalVisibilityRoleConfigCommandHandler(
|
||||
PersonalContextService personalContextService,
|
||||
ITenantVisibilityRoleRuleRepository visibilityRoleRuleRepository)
|
||||
: IRequestHandler<UpdatePersonalVisibilityRoleConfigCommand, PersonalVisibilityRoleConfigDto>
|
||||
{
|
||||
private static readonly string[] ManagerRoleCodes = ["tenant-owner", "tenant-admin"];
|
||||
|
||||
/// <summary>
|
||||
/// 处理命令。
|
||||
/// </summary>
|
||||
/// <param name="request">更新命令。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>更新后的配置。</returns>
|
||||
public async Task<PersonalVisibilityRoleConfigDto> Handle(UpdatePersonalVisibilityRoleConfigCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 获取上下文并校验管理权限
|
||||
var context = personalContextService.GetRequiredContext();
|
||||
if (!HasAnyRole(context.RoleCodes, ManagerRoleCodes))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "当前角色无权更新可见角色配置");
|
||||
}
|
||||
|
||||
// 2. 规范化角色编码集合
|
||||
var normalizedQuotaRoles = NormalizeRoleCodes(request.QuotaVisibleRoleCodes);
|
||||
var normalizedBillingRoles = NormalizeRoleCodes(request.BillingVisibleRoleCodes);
|
||||
|
||||
// 3. 查询并落库
|
||||
var existing = await visibilityRoleRuleRepository.FindByTenantIdAsync(context.TenantId, cancellationToken);
|
||||
if (existing is null)
|
||||
{
|
||||
var created = new TenantVisibilityRoleRule
|
||||
{
|
||||
TenantId = context.TenantId,
|
||||
QuotaVisibleRoleCodes = normalizedQuotaRoles,
|
||||
BillingVisibleRoleCodes = normalizedBillingRoles,
|
||||
UpdatedBy = context.UserId,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
await visibilityRoleRuleRepository.AddAsync(created, cancellationToken);
|
||||
await visibilityRoleRuleRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return new PersonalVisibilityRoleConfigDto
|
||||
{
|
||||
TenantId = created.TenantId,
|
||||
QuotaVisibleRoleCodes = created.QuotaVisibleRoleCodes,
|
||||
BillingVisibleRoleCodes = created.BillingVisibleRoleCodes,
|
||||
UpdatedBy = created.UpdatedBy ?? context.UserId,
|
||||
UpdatedAt = created.UpdatedAt ?? DateTime.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
existing.QuotaVisibleRoleCodes = normalizedQuotaRoles;
|
||||
existing.BillingVisibleRoleCodes = normalizedBillingRoles;
|
||||
existing.UpdatedBy = context.UserId;
|
||||
existing.UpdatedAt = DateTime.UtcNow;
|
||||
await visibilityRoleRuleRepository.UpdateAsync(existing, cancellationToken);
|
||||
await visibilityRoleRuleRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// 4. 返回更新结果
|
||||
return new PersonalVisibilityRoleConfigDto
|
||||
{
|
||||
TenantId = existing.TenantId,
|
||||
QuotaVisibleRoleCodes = existing.QuotaVisibleRoleCodes,
|
||||
BillingVisibleRoleCodes = existing.BillingVisibleRoleCodes,
|
||||
UpdatedBy = existing.UpdatedBy ?? context.UserId,
|
||||
UpdatedAt = existing.UpdatedAt ?? DateTime.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
private static string[] NormalizeRoleCodes(IEnumerable<string> roleCodes)
|
||||
{
|
||||
// 1. 过滤空值并去重
|
||||
return roleCodes
|
||||
.Where(static x => !string.IsNullOrWhiteSpace(x))
|
||||
.Select(static x => x.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static bool HasAnyRole(IReadOnlyList<string> currentRoles, IReadOnlyList<string> allowedRoles)
|
||||
{
|
||||
// 1. 构建当前角色集合
|
||||
var roleSet = currentRoles
|
||||
.Where(static x => !string.IsNullOrWhiteSpace(x))
|
||||
.Select(static x => x.Trim())
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// 2. 判断是否命中允许角色
|
||||
return allowedRoles.Any(roleSet.Contains);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user