feat: 完成租户个人中心 API 首版实现

This commit is contained in:
2026-02-09 20:01:11 +08:00
parent f61554fc08
commit 2711893474
53 changed files with 2547 additions and 0 deletions

View File

@@ -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
};
}
}

View File

@@ -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);
}
}

View File

@@ -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
};
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}