From 27118934740ab9d9805ee56e98dca1eefa812c8c Mon Sep 17 00:00:00 2001 From: MSuMshk <2039814060@qq.com> Date: Mon, 9 Feb 2026 20:01:11 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E6=88=90=E7=A7=9F=E6=88=B7?= =?UTF-8?q?=E4=B8=AA=E4=BA=BA=E4=B8=AD=E5=BF=83=20API=20=E9=A6=96=E7=89=88?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 9 + .../Controllers/PersonalController.cs | 245 ++++++++++++++++++ ...pApplicationServiceCollectionExtensions.cs | 9 + .../Personal/Dto/PersonalAccountProfileDto.cs | 46 ++++ .../Dto/PersonalBillingStatementDto.cs | 46 ++++ .../Personal/Dto/PersonalModuleStatusDto.cs | 32 +++ .../Personal/Dto/PersonalNotificationDto.cs | 36 +++ .../Personal/Dto/PersonalOperationLogDto.cs | 47 ++++ .../App/Personal/Dto/PersonalOverviewDto.cs | 47 ++++ .../App/Personal/Dto/PersonalPagedQuery.cs | 59 +++++ .../Personal/Dto/PersonalPaymentRecordDto.cs | 42 +++ .../Personal/Dto/PersonalQuotaUsageItemDto.cs | 37 +++ .../Dto/PersonalQuotaUsageSummaryDto.cs | 12 + .../App/Personal/Dto/PersonalRoleItemDto.cs | 17 ++ .../Dto/PersonalRolePermissionSummaryDto.cs | 17 ++ .../Dto/PersonalSecuritySnapshotDto.cs | 32 +++ .../Dto/PersonalTenantAffiliationDto.cs | 47 ++++ .../Dto/PersonalVisibilityRoleConfigDto.cs | 37 +++ .../GetPersonalOverviewQueryHandler.cs | 145 +++++++++++ .../Handlers/GetPersonalQuotaQueryHandler.cs | 109 ++++++++ .../Handlers/GetPersonalRolesQueryHandler.cs | 67 +++++ ...ersonalVisibilityRoleConfigQueryHandler.cs | 72 +++++ ...chPersonalBillingStatementsQueryHandler.cs | 108 ++++++++ ...SearchPersonalNotificationsQueryHandler.cs | 58 +++++ .../SearchPersonalOperationsQueryHandler.cs | 102 ++++++++ .../SearchPersonalPaymentsQueryHandler.cs | 117 +++++++++ ...sonalVisibilityRoleConfigCommandHandler.cs | 105 ++++++++ .../App/Personal/PersonalMapping.cs | 56 ++++ .../Queries/GetPersonalOverviewQuery.cs | 9 + .../Personal/Queries/GetPersonalQuotaQuery.cs | 9 + .../Personal/Queries/GetPersonalRolesQuery.cs | 9 + .../GetPersonalVisibilityRoleConfigQuery.cs | 9 + .../SearchPersonalBillingStatementsQuery.cs | 31 +++ .../SearchPersonalNotificationsQuery.cs | 26 ++ .../Queries/SearchPersonalOperationsQuery.cs | 31 +++ .../Queries/SearchPersonalPaymentsQuery.cs | 31 +++ ...datePersonalVisibilityRoleConfigCommand.cs | 20 ++ .../Personal/Services/PersonalAuditService.cs | 38 +++ .../Services/PersonalContextService.cs | 77 ++++++ .../Services/PersonalMaskingService.cs | 61 +++++ .../Services/PersonalModuleStatusService.cs | 101 ++++++++ .../GetPersonalOverviewQueryValidator.cs | 18 ++ .../Validators/PersonalDateRangeValidator.cs | 43 +++ ...nalVisibilityRoleConfigCommandValidator.cs | 32 +++ .../Entities/TenantVisibilityRoleRule.cs | 20 ++ .../Repositories/IOperationLogRepository.cs | 20 ++ .../Repositories/ITenantPaymentRepository.cs | 18 ++ .../ITenantVisibilityRoleRuleRepository.cs | 40 +++ .../AppServiceCollectionExtensions.cs | 1 + .../Repositories/TenantPaymentRepository.cs | 33 +++ .../TenantVisibilityRoleRuleRepository.cs | 58 +++++ .../App/Persistence/TakeoutAppDbContext.cs | 17 ++ .../Repositories/EfOperationLogRepository.cs | 39 +++ 53 files changed, 2547 insertions(+) create mode 100644 src/Api/TakeoutSaaS.TenantApi/Controllers/PersonalController.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Personal/Dto/PersonalAccountProfileDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Personal/Dto/PersonalBillingStatementDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Personal/Dto/PersonalModuleStatusDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Personal/Dto/PersonalNotificationDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Personal/Dto/PersonalOperationLogDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Personal/Dto/PersonalOverviewDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Personal/Dto/PersonalPagedQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Personal/Dto/PersonalPaymentRecordDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Personal/Dto/PersonalQuotaUsageItemDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Personal/Dto/PersonalQuotaUsageSummaryDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Personal/Dto/PersonalRoleItemDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Personal/Dto/PersonalRolePermissionSummaryDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Personal/Dto/PersonalSecuritySnapshotDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Personal/Dto/PersonalTenantAffiliationDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Personal/Dto/PersonalVisibilityRoleConfigDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Personal/Handlers/GetPersonalOverviewQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Personal/Handlers/GetPersonalQuotaQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Personal/Handlers/GetPersonalRolesQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Personal/Handlers/GetPersonalVisibilityRoleConfigQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Personal/Handlers/SearchPersonalBillingStatementsQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Personal/Handlers/SearchPersonalNotificationsQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Personal/Handlers/SearchPersonalOperationsQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Personal/Handlers/SearchPersonalPaymentsQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Personal/Handlers/UpdatePersonalVisibilityRoleConfigCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Personal/PersonalMapping.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Personal/Queries/GetPersonalOverviewQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Personal/Queries/GetPersonalQuotaQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Personal/Queries/GetPersonalRolesQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Personal/Queries/GetPersonalVisibilityRoleConfigQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Personal/Queries/SearchPersonalBillingStatementsQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Personal/Queries/SearchPersonalNotificationsQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Personal/Queries/SearchPersonalOperationsQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Personal/Queries/SearchPersonalPaymentsQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Personal/Queries/UpdatePersonalVisibilityRoleConfigCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Personal/Services/PersonalAuditService.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Personal/Services/PersonalContextService.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Personal/Services/PersonalMaskingService.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Personal/Services/PersonalModuleStatusService.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Personal/Validators/GetPersonalOverviewQueryValidator.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Personal/Validators/PersonalDateRangeValidator.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Personal/Validators/UpdatePersonalVisibilityRoleConfigCommandValidator.cs create mode 100644 src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantVisibilityRoleRule.cs create mode 100644 src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantVisibilityRoleRuleRepository.cs create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/Repositories/TenantVisibilityRoleRuleRepository.cs diff --git a/.gitignore b/.gitignore index 8c8e5b2..2346398 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,19 @@ .vs/ +.vscode/ +.idea/ bin/ obj/ **/bin/ **/obj/ .claude/ *.log +*.tmp +*.swp +*.suo +*.user +packages/ +.DS_Store +Thumbs.db /src/Api/TakeoutSaaS.AdminApi/TakeoutSaaS.AdminApi.csproj.user # 保留根目录 scripts 目录提交 diff --git a/src/Api/TakeoutSaaS.TenantApi/Controllers/PersonalController.cs b/src/Api/TakeoutSaaS.TenantApi/Controllers/PersonalController.cs new file mode 100644 index 0000000..30034ae --- /dev/null +++ b/src/Api/TakeoutSaaS.TenantApi/Controllers/PersonalController.cs @@ -0,0 +1,245 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using MediatR; +using TakeoutSaaS.Application.App.Personal.Dto; +using TakeoutSaaS.Application.App.Personal.Queries; +using TakeoutSaaS.Shared.Abstractions.Results; +using TakeoutSaaS.Shared.Web.Api; + +namespace TakeoutSaaS.TenantApi.Controllers; + +/// +/// 租户端个人中心。 +/// +/// +/// 提供个人总览、角色概览、配额、账单、支付、操作记录与消息摘要能力。 +/// +[ApiVersion("1.0")] +[Authorize] +[Produces("application/json")] +[Route("api/tenant/v{version:apiVersion}/personal")] +public sealed class PersonalController(IMediator mediator) : BaseApiController +{ + /// + /// 获取个人中心总览。 + /// + /// 取消标记。 + /// 总览结果。 + [HttpGet("overview")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status401Unauthorized)] + public async Task> GetOverview(CancellationToken cancellationToken) + { + // 1. 查询总览 + var overview = await mediator.Send(new GetPersonalOverviewQuery(), cancellationToken); + + // 2. 返回结果 + return ApiResponse.Ok(overview); + } + + /// + /// 获取我的角色与权限概览。 + /// + /// 取消标记。 + /// 角色权限概览。 + [HttpGet("roles")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status401Unauthorized)] + public async Task> GetRoles(CancellationToken cancellationToken) + { + // 1. 查询角色权限概览 + var summary = await mediator.Send(new GetPersonalRolesQuery(), cancellationToken); + + // 2. 返回结果 + return ApiResponse.Ok(summary); + } + + /// + /// 获取套餐与配额摘要。 + /// + /// 取消标记。 + /// 配额摘要。 + [HttpGet("quota")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status403Forbidden)] + public async Task> GetQuota(CancellationToken cancellationToken) + { + // 1. 查询配额摘要 + var summary = await mediator.Send(new GetPersonalQuotaQuery(), cancellationToken); + + // 2. 返回结果 + return ApiResponse.Ok(summary); + } + + /// + /// 分页查询账单记录。 + /// + /// 页码(从 1 开始)。 + /// 每页条数。 + /// 开始时间(UTC)。 + /// 结束时间(UTC)。 + /// 取消标记。 + /// 账单分页结果。 + [HttpGet("billing/statements")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status403Forbidden)] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status422UnprocessableEntity)] + public async Task>> SearchBillingStatements( + [FromQuery] int page = 1, + [FromQuery] int pageSize = 20, + [FromQuery] DateTime? from = null, + [FromQuery] DateTime? to = null, + CancellationToken cancellationToken = default) + { + // 1. 发送查询 + var result = await mediator.Send(new SearchPersonalBillingStatementsQuery + { + Page = page, + PageSize = pageSize, + From = from, + To = to + }, cancellationToken); + + // 2. 返回分页结果 + return ApiResponse>.Ok(result); + } + + /// + /// 分页查询支付记录。 + /// + /// 页码(从 1 开始)。 + /// 每页条数。 + /// 开始时间(UTC)。 + /// 结束时间(UTC)。 + /// 取消标记。 + /// 支付记录分页结果。 + [HttpGet("billing/payments")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status403Forbidden)] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status422UnprocessableEntity)] + public async Task>> SearchPayments( + [FromQuery] int page = 1, + [FromQuery] int pageSize = 20, + [FromQuery] DateTime? from = null, + [FromQuery] DateTime? to = null, + CancellationToken cancellationToken = default) + { + // 1. 发送查询 + var result = await mediator.Send(new SearchPersonalPaymentsQuery + { + Page = page, + PageSize = pageSize, + From = from, + To = to + }, cancellationToken); + + // 2. 返回分页结果 + return ApiResponse>.Ok(result); + } + + /// + /// 获取账单/配额可见角色配置。 + /// + /// 取消标记。 + /// 可见角色配置。 + [HttpGet("visibility/roles")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status403Forbidden)] + public async Task> GetVisibilityRoles(CancellationToken cancellationToken) + { + // 1. 查询配置 + var config = await mediator.Send(new GetPersonalVisibilityRoleConfigQuery(), cancellationToken); + + // 2. 返回结果 + return ApiResponse.Ok(config); + } + + /// + /// 更新账单/配额可见角色配置。 + /// + /// 更新请求。 + /// 取消标记。 + /// 更新后的配置。 + [HttpPut("visibility/roles")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status403Forbidden)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status422UnprocessableEntity)] + public async Task> UpdateVisibilityRoles( + [FromBody] UpdatePersonalVisibilityRoleConfigCommand command, + CancellationToken cancellationToken) + { + // 1. 更新配置 + var config = await mediator.Send(command, cancellationToken); + + // 2. 返回结果 + return ApiResponse.Ok(config); + } + + /// + /// 分页查询个人操作记录。 + /// + /// 页码(从 1 开始)。 + /// 每页条数(默认 50,最大 50)。 + /// 开始时间(UTC)。 + /// 结束时间(UTC)。 + /// 取消标记。 + /// 操作记录分页结果。 + [HttpGet("operations")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status422UnprocessableEntity)] + public async Task>> SearchOperations( + [FromQuery] int page = 1, + [FromQuery] int pageSize = 50, + [FromQuery] DateTime? from = null, + [FromQuery] DateTime? to = null, + CancellationToken cancellationToken = default) + { + // 1. 发送查询 + var result = await mediator.Send(new SearchPersonalOperationsQuery + { + Page = page, + PageSize = pageSize, + From = from, + To = to + }, cancellationToken); + + // 2. 返回分页结果 + return ApiResponse>.Ok(result); + } + + /// + /// 分页查询个人消息摘要。 + /// + /// 页码(从 1 开始)。 + /// 每页条数。 + /// 是否仅返回未读。 + /// 取消标记。 + /// 消息摘要分页结果。 + [HttpGet("notifications")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status422UnprocessableEntity)] + public async Task>> SearchNotifications( + [FromQuery] int page = 1, + [FromQuery] int pageSize = 20, + [FromQuery] bool unreadOnly = false, + CancellationToken cancellationToken = default) + { + // 1. 发送查询 + var result = await mediator.Send(new SearchPersonalNotificationsQuery + { + Page = page, + PageSize = pageSize, + UnreadOnly = unreadOnly + }, cancellationToken); + + // 2. 返回分页结果 + return ApiResponse>.Ok(result); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Extensions/AppApplicationServiceCollectionExtensions.cs b/src/Application/TakeoutSaaS.Application/App/Extensions/AppApplicationServiceCollectionExtensions.cs index 8f2944e..b31bbf3 100644 --- a/src/Application/TakeoutSaaS.Application/App/Extensions/AppApplicationServiceCollectionExtensions.cs +++ b/src/Application/TakeoutSaaS.Application/App/Extensions/AppApplicationServiceCollectionExtensions.cs @@ -3,6 +3,8 @@ using MediatR; using Microsoft.Extensions.DependencyInjection; using System.Reflection; using TakeoutSaaS.Application.App.Common.Behaviors; +using TakeoutSaaS.Application.App.Personal.Services; +using TakeoutSaaS.Application.App.Personal.Validators; namespace TakeoutSaaS.Application.App.Extensions; @@ -22,6 +24,13 @@ public static class AppApplicationServiceCollectionExtensions services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly()); services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>)); + // 1. 注册个人中心基础服务 + services.AddScoped(); + services.AddSingleton(); + services.AddSingleton(); + services.AddScoped(); + services.AddScoped(); + return services; } } diff --git a/src/Application/TakeoutSaaS.Application/App/Personal/Dto/PersonalAccountProfileDto.cs b/src/Application/TakeoutSaaS.Application/App/Personal/Dto/PersonalAccountProfileDto.cs new file mode 100644 index 0000000..3935b98 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Personal/Dto/PersonalAccountProfileDto.cs @@ -0,0 +1,46 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Personal.Dto; + +/// +/// 个人中心账号信息 DTO。 +/// +public sealed class PersonalAccountProfileDto +{ + /// + /// 用户 ID(雪花算法,序列化为字符串)。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long UserId { get; init; } + + /// + /// 登录账号。 + /// + public string Account { get; init; } = string.Empty; + + /// + /// 展示名称。 + /// + public string DisplayName { get; init; } = string.Empty; + + /// + /// 头像地址。 + /// + public string? AvatarUrl { get; init; } + + /// + /// 脱敏手机号。 + /// + public string? PhoneMasked { get; init; } + + /// + /// 脱敏邮箱。 + /// + public string? EmailMasked { get; init; } + + /// + /// 注册时间(UTC)。 + /// + public DateTime RegisteredAt { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Personal/Dto/PersonalBillingStatementDto.cs b/src/Application/TakeoutSaaS.Application/App/Personal/Dto/PersonalBillingStatementDto.cs new file mode 100644 index 0000000..c8d5f93 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Personal/Dto/PersonalBillingStatementDto.cs @@ -0,0 +1,46 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Personal.Dto; + +/// +/// 个人中心账单记录 DTO。 +/// +public sealed class PersonalBillingStatementDto +{ + /// + /// 账单 ID(雪花算法,序列化为字符串)。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long StatementId { get; init; } + + /// + /// 账单周期开始日期。 + /// + public DateOnly BillingPeriodStart { get; init; } + + /// + /// 账单周期结束日期。 + /// + public DateOnly BillingPeriodEnd { get; init; } + + /// + /// 应付金额。 + /// + public decimal AmountDue { get; init; } + + /// + /// 实付金额。 + /// + public decimal AmountPaid { get; init; } + + /// + /// 账单状态。 + /// + public string Status { get; init; } = string.Empty; + + /// + /// 到期时间(UTC)。 + /// + public DateTime DueAt { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Personal/Dto/PersonalModuleStatusDto.cs b/src/Application/TakeoutSaaS.Application/App/Personal/Dto/PersonalModuleStatusDto.cs new file mode 100644 index 0000000..66adbd5 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Personal/Dto/PersonalModuleStatusDto.cs @@ -0,0 +1,32 @@ +namespace TakeoutSaaS.Application.App.Personal.Dto; + +/// +/// 个人中心子模块执行状态 DTO。 +/// +public sealed class PersonalModuleStatusDto +{ + /// + /// 模块名称。 + /// + public string Module { get; init; } = string.Empty; + + /// + /// 模块状态:ok / degraded / failed / timeout / skipped。 + /// + public string Status { get; init; } = "ok"; + + /// + /// 业务错误码。 + /// + public string? ErrorCode { get; init; } + + /// + /// 错误说明。 + /// + public string? ErrorMessage { get; init; } + + /// + /// 追踪标识。 + /// + public string? TraceId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Personal/Dto/PersonalNotificationDto.cs b/src/Application/TakeoutSaaS.Application/App/Personal/Dto/PersonalNotificationDto.cs new file mode 100644 index 0000000..b6c3f46 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Personal/Dto/PersonalNotificationDto.cs @@ -0,0 +1,36 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Personal.Dto; + +/// +/// 个人中心消息摘要 DTO。 +/// +public sealed class PersonalNotificationDto +{ + /// + /// 通知 ID(雪花算法,序列化为字符串)。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long NotificationId { get; init; } + + /// + /// 标题。 + /// + public string Title { get; init; } = string.Empty; + + /// + /// 类别。 + /// + public string Category { get; init; } = string.Empty; + + /// + /// 是否已读。 + /// + public bool IsRead { get; init; } + + /// + /// 发送时间(UTC)。 + /// + public DateTime SentAt { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Personal/Dto/PersonalOperationLogDto.cs b/src/Application/TakeoutSaaS.Application/App/Personal/Dto/PersonalOperationLogDto.cs new file mode 100644 index 0000000..5d2688e --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Personal/Dto/PersonalOperationLogDto.cs @@ -0,0 +1,47 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Personal.Dto; + +/// +/// 个人中心操作记录 DTO。 +/// +public sealed class PersonalOperationLogDto +{ + /// + /// 操作记录 ID(雪花算法,序列化为字符串)。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long OperationId { get; init; } + + /// + /// 操作人用户 ID(雪花算法,序列化为字符串)。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long OperatorUserId { get; init; } + + /// + /// 操作类型。 + /// + public string ActionType { get; init; } = string.Empty; + + /// + /// 目标类型。 + /// + public string TargetType { get; init; } = string.Empty; + + /// + /// 目标 ID。 + /// + public string? TargetId { get; init; } + + /// + /// 是否成功。 + /// + public bool IsSuccess { get; init; } + + /// + /// 发生时间(UTC)。 + /// + public DateTime OccurredAt { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Personal/Dto/PersonalOverviewDto.cs b/src/Application/TakeoutSaaS.Application/App/Personal/Dto/PersonalOverviewDto.cs new file mode 100644 index 0000000..742bc22 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Personal/Dto/PersonalOverviewDto.cs @@ -0,0 +1,47 @@ +namespace TakeoutSaaS.Application.App.Personal.Dto; + +/// +/// 个人中心总览 DTO。 +/// +public sealed class PersonalOverviewDto +{ + /// + /// 请求追踪标识。 + /// + public string RequestId { get; init; } = string.Empty; + + /// + /// 总体状态:success / partial_success / failure。 + /// + public string OverallStatus { get; init; } = "success"; + + /// + /// 账号信息。 + /// + public PersonalAccountProfileDto? AccountProfile { get; init; } + + /// + /// 安全信息。 + /// + public PersonalSecuritySnapshotDto? SecuritySnapshot { get; init; } + + /// + /// 角色权限概览。 + /// + public PersonalRolePermissionSummaryDto? RoleSummary { get; init; } + + /// + /// 归属信息。 + /// + public PersonalTenantAffiliationDto? TenantAffiliation { get; init; } + + /// + /// 配额摘要(可选)。 + /// + public PersonalQuotaUsageSummaryDto? QuotaSummary { get; init; } + + /// + /// 子模块执行状态。 + /// + public IReadOnlyList ModuleStatuses { get; init; } = []; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Personal/Dto/PersonalPagedQuery.cs b/src/Application/TakeoutSaaS.Application/App/Personal/Dto/PersonalPagedQuery.cs new file mode 100644 index 0000000..98fc72a --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Personal/Dto/PersonalPagedQuery.cs @@ -0,0 +1,59 @@ +namespace TakeoutSaaS.Application.App.Personal.Dto; + +/// +/// 个人中心通用分页查询契约。 +/// +public sealed record PersonalPagedQuery +{ + /// + /// 页码,从 1 开始。 + /// + public int Page { get; init; } = 1; + + /// + /// 每页条数。 + /// + public int PageSize { get; init; } = 20; + + /// + /// 查询开始时间(UTC)。 + /// + public DateTime? From { get; init; } + + /// + /// 查询结束时间(UTC)。 + /// + public DateTime? To { get; init; } + + /// + /// 获取有效页码。 + /// + /// 最小为 1 的页码。 + public int ResolvePage() + { + // 1. 对非法页码做兜底 + return Page < 1 ? 1 : Page; + } + + /// + /// 获取有效页大小。 + /// + /// 默认页大小。 + /// 最大页大小。 + /// 落在约束范围内的页大小。 + public int ResolvePageSize(int defaultPageSize = 20, int maxPageSize = 50) + { + // 1. 兜底默认值 + var resolvedDefault = defaultPageSize <= 0 ? 20 : defaultPageSize; + var resolvedMax = maxPageSize <= 0 ? 50 : maxPageSize; + + // 2. 对非法页大小做兜底 + if (PageSize <= 0) + { + return resolvedDefault; + } + + // 3. 对超限页大小做截断 + return PageSize > resolvedMax ? resolvedMax : PageSize; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Personal/Dto/PersonalPaymentRecordDto.cs b/src/Application/TakeoutSaaS.Application/App/Personal/Dto/PersonalPaymentRecordDto.cs new file mode 100644 index 0000000..f060544 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Personal/Dto/PersonalPaymentRecordDto.cs @@ -0,0 +1,42 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Personal.Dto; + +/// +/// 个人中心支付记录 DTO。 +/// +public sealed class PersonalPaymentRecordDto +{ + /// + /// 支付记录 ID(雪花算法,序列化为字符串)。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long PaymentId { get; init; } + + /// + /// 关联账单 ID(雪花算法,序列化为字符串)。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long StatementId { get; init; } + + /// + /// 支付金额。 + /// + public decimal PaidAmount { get; init; } + + /// + /// 支付方式。 + /// + public string PaymentMethod { get; init; } = string.Empty; + + /// + /// 支付状态。 + /// + public string PaymentStatus { get; init; } = string.Empty; + + /// + /// 支付时间(UTC)。 + /// + public DateTime? PaidAt { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Personal/Dto/PersonalQuotaUsageItemDto.cs b/src/Application/TakeoutSaaS.Application/App/Personal/Dto/PersonalQuotaUsageItemDto.cs new file mode 100644 index 0000000..1a28006 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Personal/Dto/PersonalQuotaUsageItemDto.cs @@ -0,0 +1,37 @@ +namespace TakeoutSaaS.Application.App.Personal.Dto; + +/// +/// 个人中心配额项 DTO。 +/// +public sealed class PersonalQuotaUsageItemDto +{ + /// + /// 配额编码。 + /// + public string QuotaCode { get; init; } = string.Empty; + + /// + /// 配额名称。 + /// + public string QuotaName { get; init; } = string.Empty; + + /// + /// 配额上限值。 + /// + public decimal LimitValue { get; init; } + + /// + /// 已用值。 + /// + public decimal UsedValue { get; init; } + + /// + /// 单位。 + /// + public string Unit { get; init; } = string.Empty; + + /// + /// 使用比例。 + /// + public decimal UsageRatio { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Personal/Dto/PersonalQuotaUsageSummaryDto.cs b/src/Application/TakeoutSaaS.Application/App/Personal/Dto/PersonalQuotaUsageSummaryDto.cs new file mode 100644 index 0000000..02f91df --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Personal/Dto/PersonalQuotaUsageSummaryDto.cs @@ -0,0 +1,12 @@ +namespace TakeoutSaaS.Application.App.Personal.Dto; + +/// +/// 个人中心配额摘要 DTO。 +/// +public sealed class PersonalQuotaUsageSummaryDto +{ + /// + /// 配额项集合。 + /// + public IReadOnlyList Items { get; init; } = []; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Personal/Dto/PersonalRoleItemDto.cs b/src/Application/TakeoutSaaS.Application/App/Personal/Dto/PersonalRoleItemDto.cs new file mode 100644 index 0000000..3259cc5 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Personal/Dto/PersonalRoleItemDto.cs @@ -0,0 +1,17 @@ +namespace TakeoutSaaS.Application.App.Personal.Dto; + +/// +/// 个人中心角色项 DTO。 +/// +public sealed class PersonalRoleItemDto +{ + /// + /// 角色编码。 + /// + public string RoleCode { get; init; } = string.Empty; + + /// + /// 角色名称。 + /// + public string RoleName { get; init; } = string.Empty; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Personal/Dto/PersonalRolePermissionSummaryDto.cs b/src/Application/TakeoutSaaS.Application/App/Personal/Dto/PersonalRolePermissionSummaryDto.cs new file mode 100644 index 0000000..8e5ed2d --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Personal/Dto/PersonalRolePermissionSummaryDto.cs @@ -0,0 +1,17 @@ +namespace TakeoutSaaS.Application.App.Personal.Dto; + +/// +/// 个人中心角色与权限概览 DTO。 +/// +public sealed class PersonalRolePermissionSummaryDto +{ + /// + /// 角色集合。 + /// + public IReadOnlyList Roles { get; init; } = []; + + /// + /// 权限数量。 + /// + public int PermissionCount { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Personal/Dto/PersonalSecuritySnapshotDto.cs b/src/Application/TakeoutSaaS.Application/App/Personal/Dto/PersonalSecuritySnapshotDto.cs new file mode 100644 index 0000000..155f185 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Personal/Dto/PersonalSecuritySnapshotDto.cs @@ -0,0 +1,32 @@ +namespace TakeoutSaaS.Application.App.Personal.Dto; + +/// +/// 个人中心安全状态快照 DTO。 +/// +public sealed class PersonalSecuritySnapshotDto +{ + /// + /// 最近登录时间(UTC)。 + /// + public DateTime? LastLoginAt { get; init; } + + /// + /// 失败登录次数。 + /// + public int FailedLoginCount { get; init; } + + /// + /// 是否已锁定。 + /// + public bool IsLocked { get; init; } + + /// + /// 锁定截止时间(UTC)。 + /// + public DateTime? LockedUntil { get; init; } + + /// + /// 是否强制修改密码。 + /// + public bool IsForceChangePassword { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Personal/Dto/PersonalTenantAffiliationDto.cs b/src/Application/TakeoutSaaS.Application/App/Personal/Dto/PersonalTenantAffiliationDto.cs new file mode 100644 index 0000000..d44e0e0 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Personal/Dto/PersonalTenantAffiliationDto.cs @@ -0,0 +1,47 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Personal.Dto; + +/// +/// 个人中心租户归属信息 DTO。 +/// +public sealed class PersonalTenantAffiliationDto +{ + /// + /// 租户 ID(雪花算法,序列化为字符串)。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long TenantId { get; init; } + + /// + /// 租户名称。 + /// + public string TenantName { get; init; } = string.Empty; + + /// + /// 商户 ID(雪花算法,序列化为字符串)。 + /// + [JsonConverter(typeof(NullableSnowflakeIdJsonConverter))] + public long? MerchantId { get; init; } + + /// + /// 商户名称。 + /// + public string? MerchantName { get; init; } + + /// + /// 商户状态。 + /// + public string MerchantStatus { get; init; } = string.Empty; + + /// + /// 当前套餐名称。 + /// + public string? PackageName { get; init; } + + /// + /// 订阅到期时间(UTC)。 + /// + public DateTime? SubscriptionExpireAt { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Personal/Dto/PersonalVisibilityRoleConfigDto.cs b/src/Application/TakeoutSaaS.Application/App/Personal/Dto/PersonalVisibilityRoleConfigDto.cs new file mode 100644 index 0000000..a6f2c7b --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Personal/Dto/PersonalVisibilityRoleConfigDto.cs @@ -0,0 +1,37 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Personal.Dto; + +/// +/// 个人中心账单/配额可见角色配置 DTO。 +/// +public sealed class PersonalVisibilityRoleConfigDto +{ + /// + /// 租户 ID(雪花算法,序列化为字符串)。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long TenantId { get; init; } + + /// + /// 配额可见角色编码集合。 + /// + public IReadOnlyList QuotaVisibleRoleCodes { get; init; } = []; + + /// + /// 账单可见角色编码集合。 + /// + public IReadOnlyList BillingVisibleRoleCodes { get; init; } = []; + + /// + /// 最近更新人(雪花算法,序列化为字符串)。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long UpdatedBy { get; init; } + + /// + /// 最近更新时间(UTC)。 + /// + public DateTime UpdatedAt { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Personal/Handlers/GetPersonalOverviewQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Personal/Handlers/GetPersonalOverviewQueryHandler.cs new file mode 100644 index 0000000..6ab135f --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Personal/Handlers/GetPersonalOverviewQueryHandler.cs @@ -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; + +/// +/// 获取个人中心总览处理器。 +/// +public sealed class GetPersonalOverviewQueryHandler( + PersonalContextService personalContextService, + PersonalMaskingService personalMaskingService, + PersonalModuleStatusService moduleStatusService, + IIdentityUserRepository identityUserRepository, + ITenantRepository tenantRepository, + ITenantPackageRepository tenantPackageRepository, + IMerchantRepository merchantRepository, + IMediator mediator) + : IRequestHandler +{ + /// + /// 处理查询。 + /// + /// 查询请求。 + /// 取消标记。 + /// 总览结果。 + public async Task Handle(GetPersonalOverviewQuery request, CancellationToken cancellationToken) + { + // 1. 获取必需上下文 + var context = personalContextService.GetRequiredContext(); + var traceId = context.TraceId; + + // 2. 初始化总览容器 + var moduleStatuses = new List(); + 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 + }; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Personal/Handlers/GetPersonalQuotaQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Personal/Handlers/GetPersonalQuotaQueryHandler.cs new file mode 100644 index 0000000..dc6f446 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Personal/Handlers/GetPersonalQuotaQueryHandler.cs @@ -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; + +/// +/// 获取个人中心配额摘要处理器。 +/// +public sealed class GetPersonalQuotaQueryHandler( + PersonalContextService personalContextService, + PersonalAuditService personalAuditService, + ITenantQuotaUsageRepository tenantQuotaUsageRepository, + ITenantVisibilityRoleRuleRepository visibilityRoleRuleRepository) + : IRequestHandler +{ + private static readonly string[] DefaultQuotaVisibleRoles = ["tenant-owner", "tenant-admin"]; + + /// + /// 处理查询。 + /// + /// 查询请求。 + /// 取消标记。 + /// 配额摘要。 + public async Task 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 currentRoles, IReadOnlyList 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); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Personal/Handlers/GetPersonalRolesQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Personal/Handlers/GetPersonalRolesQueryHandler.cs new file mode 100644 index 0000000..fea73f3 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Personal/Handlers/GetPersonalRolesQueryHandler.cs @@ -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; + +/// +/// 获取个人角色与权限概览处理器。 +/// +public sealed class GetPersonalRolesQueryHandler( + PersonalContextService personalContextService, + IUserRoleRepository userRoleRepository, + IRoleRepository roleRepository, + IRolePermissionRepository rolePermissionRepository) + : IRequestHandler +{ + /// + /// 处理查询。 + /// + /// 查询请求。 + /// 取消标记。 + /// 角色权限概览。 + public async Task 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 + }; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Personal/Handlers/GetPersonalVisibilityRoleConfigQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Personal/Handlers/GetPersonalVisibilityRoleConfigQueryHandler.cs new file mode 100644 index 0000000..2d0c1b5 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Personal/Handlers/GetPersonalVisibilityRoleConfigQueryHandler.cs @@ -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; + +/// +/// 获取个人中心可见角色配置处理器。 +/// +public sealed class GetPersonalVisibilityRoleConfigQueryHandler( + PersonalContextService personalContextService, + ITenantVisibilityRoleRuleRepository visibilityRoleRuleRepository) + : IRequestHandler +{ + private static readonly string[] ManagerRoleCodes = ["tenant-owner", "tenant-admin"]; + private static readonly string[] DefaultVisibleRoleCodes = ["tenant-owner", "tenant-admin"]; + + /// + /// 处理查询。 + /// + /// 查询请求。 + /// 取消标记。 + /// 可见角色配置。 + public async Task 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 currentRoles, IReadOnlyList 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); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Personal/Handlers/SearchPersonalBillingStatementsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Personal/Handlers/SearchPersonalBillingStatementsQueryHandler.cs new file mode 100644 index 0000000..4db5ee6 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Personal/Handlers/SearchPersonalBillingStatementsQueryHandler.cs @@ -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; + +/// +/// 分页查询个人中心账单记录处理器。 +/// +public sealed class SearchPersonalBillingStatementsQueryHandler( + PersonalContextService personalContextService, + PersonalDateRangeValidator personalDateRangeValidator, + PersonalAuditService personalAuditService, + ITenantBillingRepository tenantBillingRepository, + ITenantVisibilityRoleRuleRepository visibilityRoleRuleRepository) + : IRequestHandler> +{ + private static readonly string[] DefaultBillingVisibleRoles = ["tenant-owner", "tenant-admin"]; + + /// + /// 处理查询。 + /// + /// 查询请求。 + /// 取消标记。 + /// 账单分页结果。 + public async Task> 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(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 currentRoles, IReadOnlyList 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); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Personal/Handlers/SearchPersonalNotificationsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Personal/Handlers/SearchPersonalNotificationsQueryHandler.cs new file mode 100644 index 0000000..7ad538e --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Personal/Handlers/SearchPersonalNotificationsQueryHandler.cs @@ -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; + +/// +/// 分页查询个人消息摘要处理器。 +/// +public sealed class SearchPersonalNotificationsQueryHandler( + PersonalContextService personalContextService, + ITenantNotificationRepository tenantNotificationRepository) + : IRequestHandler> +{ + /// + /// 处理查询。 + /// + /// 查询请求。 + /// 取消标记。 + /// 消息摘要分页结果。 + public async Task> 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(items, page, pageSize, ordered.Count); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Personal/Handlers/SearchPersonalOperationsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Personal/Handlers/SearchPersonalOperationsQueryHandler.cs new file mode 100644 index 0000000..de6e3f8 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Personal/Handlers/SearchPersonalOperationsQueryHandler.cs @@ -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; + +/// +/// 分页查询个人操作记录处理器。 +/// +public sealed class SearchPersonalOperationsQueryHandler( + PersonalContextService personalContextService, + PersonalDateRangeValidator personalDateRangeValidator, + IOperationLogRepository operationLogRepository) + : IRequestHandler> +{ + /// + /// 处理查询。 + /// + /// 查询请求。 + /// 取消标记。 + /// 操作记录分页结果。 + public async Task> 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(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; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Personal/Handlers/SearchPersonalPaymentsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Personal/Handlers/SearchPersonalPaymentsQueryHandler.cs new file mode 100644 index 0000000..28cb87c --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Personal/Handlers/SearchPersonalPaymentsQueryHandler.cs @@ -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; + +/// +/// 分页查询个人中心支付记录处理器。 +/// +public sealed class SearchPersonalPaymentsQueryHandler( + PersonalContextService personalContextService, + PersonalDateRangeValidator personalDateRangeValidator, + PersonalAuditService personalAuditService, + ITenantPaymentRepository tenantPaymentRepository, + ITenantVisibilityRoleRuleRepository visibilityRoleRuleRepository) + : IRequestHandler> +{ + private static readonly string[] DefaultBillingVisibleRoles = ["tenant-owner", "tenant-admin"]; + + /// + /// 处理查询。 + /// + /// 查询请求。 + /// 取消标记。 + /// 支付记录分页结果。 + public async Task> 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(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 currentRoles, IReadOnlyList 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); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Personal/Handlers/UpdatePersonalVisibilityRoleConfigCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Personal/Handlers/UpdatePersonalVisibilityRoleConfigCommandHandler.cs new file mode 100644 index 0000000..8ab3615 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Personal/Handlers/UpdatePersonalVisibilityRoleConfigCommandHandler.cs @@ -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; + +/// +/// 更新个人中心可见角色配置处理器。 +/// +public sealed class UpdatePersonalVisibilityRoleConfigCommandHandler( + PersonalContextService personalContextService, + ITenantVisibilityRoleRuleRepository visibilityRoleRuleRepository) + : IRequestHandler +{ + private static readonly string[] ManagerRoleCodes = ["tenant-owner", "tenant-admin"]; + + /// + /// 处理命令。 + /// + /// 更新命令。 + /// 取消标记。 + /// 更新后的配置。 + public async Task 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 roleCodes) + { + // 1. 过滤空值并去重 + return roleCodes + .Where(static x => !string.IsNullOrWhiteSpace(x)) + .Select(static x => x.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + + private static bool HasAnyRole(IReadOnlyList currentRoles, IReadOnlyList 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); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Personal/PersonalMapping.cs b/src/Application/TakeoutSaaS.Application/App/Personal/PersonalMapping.cs new file mode 100644 index 0000000..73477da --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Personal/PersonalMapping.cs @@ -0,0 +1,56 @@ +using TakeoutSaaS.Application.App.Personal.Dto; +using TakeoutSaaS.Shared.Abstractions.Results; + +namespace TakeoutSaaS.Application.App.Personal; + +/// +/// 个人中心映射辅助。 +/// +public static class PersonalMapping +{ + /// + /// 映射分页结果。 + /// + /// 源类型。 + /// 目标类型。 + /// 源分页结果。 + /// 单项映射函数。 + /// 目标分页结果。 + public static PagedResult ToPagedResult( + this PagedResult source, + Func mapper) + { + // 1. 映射分页项集合 + var items = source.Items.Select(mapper).ToList(); + + // 2. 返回目标分页对象 + return new PagedResult(items, source.Page, source.PageSize, source.TotalCount); + } + + /// + /// 便捷构建模块状态 DTO。 + /// + /// 模块名称。 + /// 模块状态。 + /// 错误码。 + /// 错误信息。 + /// 追踪标识。 + /// 模块状态 DTO。 + public static PersonalModuleStatusDto ToModuleStatus( + this string module, + string status, + string? errorCode = null, + string? errorMessage = null, + string? traceId = null) + { + // 1. 构建通用模块状态 + return new PersonalModuleStatusDto + { + Module = module, + Status = status, + ErrorCode = errorCode, + ErrorMessage = errorMessage, + TraceId = traceId + }; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Personal/Queries/GetPersonalOverviewQuery.cs b/src/Application/TakeoutSaaS.Application/App/Personal/Queries/GetPersonalOverviewQuery.cs new file mode 100644 index 0000000..a15fbf1 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Personal/Queries/GetPersonalOverviewQuery.cs @@ -0,0 +1,9 @@ +using MediatR; +using TakeoutSaaS.Application.App.Personal.Dto; + +namespace TakeoutSaaS.Application.App.Personal.Queries; + +/// +/// 获取个人中心总览查询。 +/// +public sealed record GetPersonalOverviewQuery : IRequest; diff --git a/src/Application/TakeoutSaaS.Application/App/Personal/Queries/GetPersonalQuotaQuery.cs b/src/Application/TakeoutSaaS.Application/App/Personal/Queries/GetPersonalQuotaQuery.cs new file mode 100644 index 0000000..0c3fed4 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Personal/Queries/GetPersonalQuotaQuery.cs @@ -0,0 +1,9 @@ +using MediatR; +using TakeoutSaaS.Application.App.Personal.Dto; + +namespace TakeoutSaaS.Application.App.Personal.Queries; + +/// +/// 获取个人中心配额摘要查询。 +/// +public sealed record GetPersonalQuotaQuery : IRequest; diff --git a/src/Application/TakeoutSaaS.Application/App/Personal/Queries/GetPersonalRolesQuery.cs b/src/Application/TakeoutSaaS.Application/App/Personal/Queries/GetPersonalRolesQuery.cs new file mode 100644 index 0000000..d818964 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Personal/Queries/GetPersonalRolesQuery.cs @@ -0,0 +1,9 @@ +using MediatR; +using TakeoutSaaS.Application.App.Personal.Dto; + +namespace TakeoutSaaS.Application.App.Personal.Queries; + +/// +/// 获取个人角色权限概览查询。 +/// +public sealed record GetPersonalRolesQuery : IRequest; diff --git a/src/Application/TakeoutSaaS.Application/App/Personal/Queries/GetPersonalVisibilityRoleConfigQuery.cs b/src/Application/TakeoutSaaS.Application/App/Personal/Queries/GetPersonalVisibilityRoleConfigQuery.cs new file mode 100644 index 0000000..8bbabd4 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Personal/Queries/GetPersonalVisibilityRoleConfigQuery.cs @@ -0,0 +1,9 @@ +using MediatR; +using TakeoutSaaS.Application.App.Personal.Dto; + +namespace TakeoutSaaS.Application.App.Personal.Queries; + +/// +/// 获取个人中心可见角色配置查询。 +/// +public sealed record GetPersonalVisibilityRoleConfigQuery : IRequest; diff --git a/src/Application/TakeoutSaaS.Application/App/Personal/Queries/SearchPersonalBillingStatementsQuery.cs b/src/Application/TakeoutSaaS.Application/App/Personal/Queries/SearchPersonalBillingStatementsQuery.cs new file mode 100644 index 0000000..47ac827 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Personal/Queries/SearchPersonalBillingStatementsQuery.cs @@ -0,0 +1,31 @@ +using MediatR; +using TakeoutSaaS.Application.App.Personal.Dto; +using TakeoutSaaS.Shared.Abstractions.Results; + +namespace TakeoutSaaS.Application.App.Personal.Queries; + +/// +/// 分页查询个人中心账单记录。 +/// +public sealed record SearchPersonalBillingStatementsQuery : IRequest> +{ + /// + /// 页码。 + /// + public int Page { get; init; } = 1; + + /// + /// 每页条数。 + /// + public int PageSize { get; init; } = 20; + + /// + /// 开始时间(UTC)。 + /// + public DateTime? From { get; init; } + + /// + /// 结束时间(UTC)。 + /// + public DateTime? To { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Personal/Queries/SearchPersonalNotificationsQuery.cs b/src/Application/TakeoutSaaS.Application/App/Personal/Queries/SearchPersonalNotificationsQuery.cs new file mode 100644 index 0000000..a871c8a --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Personal/Queries/SearchPersonalNotificationsQuery.cs @@ -0,0 +1,26 @@ +using MediatR; +using TakeoutSaaS.Application.App.Personal.Dto; +using TakeoutSaaS.Shared.Abstractions.Results; + +namespace TakeoutSaaS.Application.App.Personal.Queries; + +/// +/// 分页查询个人消息摘要。 +/// +public sealed record SearchPersonalNotificationsQuery : IRequest> +{ + /// + /// 页码。 + /// + public int Page { get; init; } = 1; + + /// + /// 每页条数。 + /// + public int PageSize { get; init; } = 20; + + /// + /// 是否仅返回未读。 + /// + public bool UnreadOnly { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Personal/Queries/SearchPersonalOperationsQuery.cs b/src/Application/TakeoutSaaS.Application/App/Personal/Queries/SearchPersonalOperationsQuery.cs new file mode 100644 index 0000000..90c624b --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Personal/Queries/SearchPersonalOperationsQuery.cs @@ -0,0 +1,31 @@ +using MediatR; +using TakeoutSaaS.Application.App.Personal.Dto; +using TakeoutSaaS.Shared.Abstractions.Results; + +namespace TakeoutSaaS.Application.App.Personal.Queries; + +/// +/// 分页查询个人操作记录。 +/// +public sealed record SearchPersonalOperationsQuery : IRequest> +{ + /// + /// 页码。 + /// + public int Page { get; init; } = 1; + + /// + /// 每页条数(默认 50,上限 50)。 + /// + public int PageSize { get; init; } = 50; + + /// + /// 开始时间(UTC)。 + /// + public DateTime? From { get; init; } + + /// + /// 结束时间(UTC)。 + /// + public DateTime? To { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Personal/Queries/SearchPersonalPaymentsQuery.cs b/src/Application/TakeoutSaaS.Application/App/Personal/Queries/SearchPersonalPaymentsQuery.cs new file mode 100644 index 0000000..95715bc --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Personal/Queries/SearchPersonalPaymentsQuery.cs @@ -0,0 +1,31 @@ +using MediatR; +using TakeoutSaaS.Application.App.Personal.Dto; +using TakeoutSaaS.Shared.Abstractions.Results; + +namespace TakeoutSaaS.Application.App.Personal.Queries; + +/// +/// 分页查询个人中心支付记录。 +/// +public sealed record SearchPersonalPaymentsQuery : IRequest> +{ + /// + /// 页码。 + /// + public int Page { get; init; } = 1; + + /// + /// 每页条数。 + /// + public int PageSize { get; init; } = 20; + + /// + /// 开始时间(UTC)。 + /// + public DateTime? From { get; init; } + + /// + /// 结束时间(UTC)。 + /// + public DateTime? To { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Personal/Queries/UpdatePersonalVisibilityRoleConfigCommand.cs b/src/Application/TakeoutSaaS.Application/App/Personal/Queries/UpdatePersonalVisibilityRoleConfigCommand.cs new file mode 100644 index 0000000..7da8851 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Personal/Queries/UpdatePersonalVisibilityRoleConfigCommand.cs @@ -0,0 +1,20 @@ +using MediatR; +using TakeoutSaaS.Application.App.Personal.Dto; + +namespace TakeoutSaaS.Application.App.Personal.Queries; + +/// +/// 更新个人中心可见角色配置命令。 +/// +public sealed record UpdatePersonalVisibilityRoleConfigCommand : IRequest +{ + /// + /// 配额可见角色编码集合。 + /// + public string[] QuotaVisibleRoleCodes { get; init; } = []; + + /// + /// 账单可见角色编码集合。 + /// + public string[] BillingVisibleRoleCodes { get; init; } = []; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Personal/Services/PersonalAuditService.cs b/src/Application/TakeoutSaaS.Application/App/Personal/Services/PersonalAuditService.cs new file mode 100644 index 0000000..efef45c --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Personal/Services/PersonalAuditService.cs @@ -0,0 +1,38 @@ +using Microsoft.Extensions.Logging; + +namespace TakeoutSaaS.Application.App.Personal.Services; + +/// +/// 个人中心敏感查询审计服务。 +/// +public sealed class PersonalAuditService( + PersonalContextService personalContextService, + ILogger logger) +{ + /// + /// 记录敏感查询审计日志。 + /// + /// 模块名称。 + /// 是否成功。 + /// 审计说明。 + /// 取消标记。 + /// 异步任务。 + public Task RecordSensitiveQueryAsync(string module, bool isSuccess, string? detail, CancellationToken cancellationToken = default) + { + // 1. 获取当前上下文 + var context = personalContextService.GetRequiredContext(); + + // 2. 写入结构化审计日志 + logger.LogInformation( + "Personal sensitive query audited. Module={Module}, TenantId={TenantId}, UserId={UserId}, Success={Success}, TraceId={TraceId}, Detail={Detail}", + module, + context.TenantId, + context.UserId, + isSuccess, + context.TraceId, + detail); + + // 3. 返回完成任务 + return Task.CompletedTask; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Personal/Services/PersonalContextService.cs b/src/Application/TakeoutSaaS.Application/App/Personal/Services/PersonalContextService.cs new file mode 100644 index 0000000..596fa81 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Personal/Services/PersonalContextService.cs @@ -0,0 +1,77 @@ +using Microsoft.AspNetCore.Http; +using System.Security.Claims; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Security; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Personal.Services; + +/// +/// 个人中心请求上下文服务。 +/// +public sealed class PersonalContextService( + ICurrentUserAccessor currentUserAccessor, + ITenantProvider tenantProvider, + IHttpContextAccessor httpContextAccessor) +{ + /// + /// 获取当前请求必需上下文。 + /// + /// 用户 ID、租户 ID、角色编码集合、追踪标识。 + public (long UserId, long TenantId, IReadOnlyList RoleCodes, string TraceId) GetRequiredContext() + { + // 1. 读取用户上下文 + var userId = currentUserAccessor.UserId; + if (userId <= 0) + { + throw new BusinessException(ErrorCodes.Unauthorized, "未登录或登录已过期"); + } + + // 2. 读取租户上下文 + var tenantId = tenantProvider.GetCurrentTenantId(); + if (tenantId <= 0) + { + throw new BusinessException(ErrorCodes.BadRequest, "缺少租户标识"); + } + + // 3. 读取角色与追踪标识 + var roleCodes = ResolveRoleCodes(); + var traceId = ResolveTraceId(); + return (userId, tenantId, roleCodes, traceId); + } + + /// + /// 获取当前请求 TraceId。 + /// + /// TraceId,若不存在则返回空字符串。 + public string ResolveTraceId() + { + // 1. 从 Activity 与 HttpContext 中解析 TraceId + var traceId = System.Diagnostics.Activity.Current?.TraceId.ToString() + ?? httpContextAccessor.HttpContext?.TraceIdentifier + ?? string.Empty; + + // 2. 返回追踪标识 + return traceId; + } + + /// + /// 获取当前用户角色编码。 + /// + /// 角色编码集合。 + public IReadOnlyList ResolveRoleCodes() + { + // 1. 读取 Claim 角色值 + var claims = httpContextAccessor.HttpContext?.User?.Claims ?? []; + var roles = claims + .Where(static c => c.Type == ClaimTypes.Role || c.Type == "role") + .Select(static c => c.Value) + .Where(static x => !string.IsNullOrWhiteSpace(x)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + // 2. 返回去重后的角色编码 + return roles; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Personal/Services/PersonalMaskingService.cs b/src/Application/TakeoutSaaS.Application/App/Personal/Services/PersonalMaskingService.cs new file mode 100644 index 0000000..18dbd8d --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Personal/Services/PersonalMaskingService.cs @@ -0,0 +1,61 @@ +namespace TakeoutSaaS.Application.App.Personal.Services; + +/// +/// 个人中心敏感信息脱敏服务。 +/// +public sealed class PersonalMaskingService +{ + /// + /// 手机号脱敏。 + /// + /// 原始手机号。 + /// 脱敏手机号。 + public string? MaskPhone(string? phone) + { + // 1. 空值直接返回 + if (string.IsNullOrWhiteSpace(phone)) + { + return null; + } + + // 2. 长度不足时按固定策略脱敏 + if (phone.Length < 7) + { + return $"{phone[..1]}***"; + } + + // 3. 标准手机号保留前三后四 + return $"{phone[..3]}****{phone[^4..]}"; + } + + /// + /// 邮箱脱敏。 + /// + /// 原始邮箱。 + /// 脱敏邮箱。 + public string? MaskEmail(string? email) + { + // 1. 空值直接返回 + if (string.IsNullOrWhiteSpace(email)) + { + return null; + } + + // 2. 非法格式按通用策略脱敏 + var atIndex = email.IndexOf('@'); + if (atIndex <= 0 || atIndex == email.Length - 1) + { + return "***"; + } + + // 3. 合法邮箱保留首尾字符和域名 + var localPart = email[..atIndex]; + var domainPart = email[atIndex..]; + if (localPart.Length == 1) + { + return $"{localPart[0]}***{domainPart}"; + } + + return $"{localPart[0]}***{localPart[^1]}{domainPart}"; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Personal/Services/PersonalModuleStatusService.cs b/src/Application/TakeoutSaaS.Application/App/Personal/Services/PersonalModuleStatusService.cs new file mode 100644 index 0000000..5e3d43e --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Personal/Services/PersonalModuleStatusService.cs @@ -0,0 +1,101 @@ +using TakeoutSaaS.Application.App.Personal.Dto; + +namespace TakeoutSaaS.Application.App.Personal.Services; + +/// +/// 个人中心模块状态构建服务。 +/// +public sealed class PersonalModuleStatusService +{ + /// + /// 构建成功状态。 + /// + /// 模块名称。 + /// 追踪标识。 + /// 模块状态 DTO。 + public PersonalModuleStatusDto BuildOk(string module, string? traceId = null) + { + // 1. 生成成功状态 + return new PersonalModuleStatusDto + { + Module = module, + Status = "ok", + TraceId = traceId + }; + } + + /// + /// 构建失败或降级状态。 + /// + /// 模块名称。 + /// 状态值:degraded/failed/timeout/skipped。 + /// 错误码。 + /// 错误说明。 + /// 追踪标识。 + /// 模块状态 DTO。 + public PersonalModuleStatusDto BuildIssue( + string module, + string status, + string? errorCode, + string? errorMessage, + string? traceId = null) + { + // 1. 规范状态值 + var normalizedStatus = NormalizeStatus(status); + + // 2. 生成异常状态 + return new PersonalModuleStatusDto + { + Module = module, + Status = normalizedStatus, + ErrorCode = errorCode, + ErrorMessage = errorMessage, + TraceId = traceId + }; + } + + /// + /// 计算总览整体状态。 + /// + /// 模块状态集合。 + /// 是否存在可用数据。 + /// overallStatus 值。 + public string ResolveOverallStatus(IReadOnlyCollection moduleStatuses, bool hasAnyData) + { + // 1. 无模块时按是否有数据判定 + if (moduleStatuses.Count == 0) + { + return hasAnyData ? "success" : "failure"; + } + + // 2. 全部成功直接返回 success + var hasIssue = moduleStatuses.Any(static x => !string.Equals(x.Status, "ok", StringComparison.OrdinalIgnoreCase)); + if (!hasIssue) + { + return "success"; + } + + // 3. 存在降级时按可用数据判定 + return hasAnyData ? "partial_success" : "failure"; + } + + private static string NormalizeStatus(string status) + { + // 1. 空状态统一按 failed 处理 + if (string.IsNullOrWhiteSpace(status)) + { + return "failed"; + } + + // 2. 限定可用状态值 + return status.ToLowerInvariant() switch + { + "ok" => "ok", + "degraded" => "degraded", + "failed" => "failed", + "timeout" => "timeout", + "skipped" => "skipped", + _ => "failed" + }; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Personal/Validators/GetPersonalOverviewQueryValidator.cs b/src/Application/TakeoutSaaS.Application/App/Personal/Validators/GetPersonalOverviewQueryValidator.cs new file mode 100644 index 0000000..479ac7a --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Personal/Validators/GetPersonalOverviewQueryValidator.cs @@ -0,0 +1,18 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Personal.Queries; + +namespace TakeoutSaaS.Application.App.Personal.Validators; + +/// +/// 个人中心总览查询校验器。 +/// +public sealed class GetPersonalOverviewQueryValidator : AbstractValidator +{ + /// + /// 初始化校验规则。 + /// + public GetPersonalOverviewQueryValidator() + { + // 1. 当前查询无显式参数,保留验证器用于后续扩展 + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Personal/Validators/PersonalDateRangeValidator.cs b/src/Application/TakeoutSaaS.Application/App/Personal/Validators/PersonalDateRangeValidator.cs new file mode 100644 index 0000000..b196489 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Personal/Validators/PersonalDateRangeValidator.cs @@ -0,0 +1,43 @@ +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; + +namespace TakeoutSaaS.Application.App.Personal.Validators; + +/// +/// 个人中心通用时间范围校验器。 +/// +public sealed class PersonalDateRangeValidator +{ + private const int DefaultDays = 90; + private const int MaxRangeDays = 365; + + /// + /// 解析并校验时间范围。 + /// + /// 起始时间。 + /// 截止时间。 + /// 校验后的起止时间(UTC)。 + public (DateTime From, DateTime To) Resolve(DateTime? from, DateTime? to) + { + // 1. 计算默认时间窗 + var utcNow = DateTime.UtcNow; + var resolvedTo = to?.ToUniversalTime() ?? utcNow; + var resolvedFrom = from?.ToUniversalTime() ?? resolvedTo.AddDays(-DefaultDays); + + // 2. 校验起止先后关系 + if (resolvedFrom > resolvedTo) + { + throw new BusinessException(ErrorCodes.ValidationFailed, "时间范围非法:from 不能大于 to"); + } + + // 3. 校验跨度上限 + var spanDays = (resolvedTo - resolvedFrom).TotalDays; + if (spanDays > MaxRangeDays) + { + throw new BusinessException(ErrorCodes.ValidationFailed, $"时间范围非法:跨度不能超过 {MaxRangeDays} 天"); + } + + // 4. 返回校验后的时间范围 + return (resolvedFrom, resolvedTo); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Personal/Validators/UpdatePersonalVisibilityRoleConfigCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Personal/Validators/UpdatePersonalVisibilityRoleConfigCommandValidator.cs new file mode 100644 index 0000000..7c9a806 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Personal/Validators/UpdatePersonalVisibilityRoleConfigCommandValidator.cs @@ -0,0 +1,32 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Personal.Queries; + +namespace TakeoutSaaS.Application.App.Personal.Validators; + +/// +/// 更新个人中心可见角色配置命令校验器。 +/// +public sealed class UpdatePersonalVisibilityRoleConfigCommandValidator : AbstractValidator +{ + /// + /// 初始化校验规则。 + /// + public UpdatePersonalVisibilityRoleConfigCommandValidator() + { + RuleFor(x => x.QuotaVisibleRoleCodes) + .NotEmpty() + .Must(HasNoBlankValue) + .WithMessage("配额可见角色列表不能为空且不能包含空白角色编码"); + + RuleFor(x => x.BillingVisibleRoleCodes) + .NotEmpty() + .Must(HasNoBlankValue) + .WithMessage("账单可见角色列表不能为空且不能包含空白角色编码"); + } + + private static bool HasNoBlankValue(IEnumerable roleCodes) + { + // 1. 校验角色编码列表不包含空值 + return roleCodes.All(static x => !string.IsNullOrWhiteSpace(x)); + } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantVisibilityRoleRule.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantVisibilityRoleRule.cs new file mode 100644 index 0000000..378579c --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantVisibilityRoleRule.cs @@ -0,0 +1,20 @@ +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Tenants.Entities; + +/// +/// 租户账单/配额可见角色规则。 +/// +public sealed class TenantVisibilityRoleRule : MultiTenantEntityBase +{ + /// + /// 配额可见角色编码集合。 + /// + public string[] QuotaVisibleRoleCodes { get; set; } = []; + + /// + /// 账单可见角色编码集合。 + /// + public string[] BillingVisibleRoleCodes { get; set; } = []; + +} diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/IOperationLogRepository.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/IOperationLogRepository.cs index f8b0333..1285f1b 100644 --- a/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/IOperationLogRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/IOperationLogRepository.cs @@ -7,6 +7,26 @@ namespace TakeoutSaaS.Domain.Tenants.Repositories; /// public interface IOperationLogRepository { + /// + /// 分页查询指定操作人的操作日志。 + /// + /// 租户 ID。 + /// 操作人标识。 + /// 开始时间(UTC)。 + /// 结束时间(UTC)。 + /// 页码(从 1 开始)。 + /// 每页条数。 + /// 取消标记。 + /// 分页结果(数据与总数)。 + Task<(IReadOnlyList Items, int Total)> SearchByOperatorPagedAsync( + long tenantId, + string operatorId, + DateTime from, + DateTime to, + int page, + int pageSize, + CancellationToken cancellationToken = default); + /// /// 新增操作日志。 /// diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantPaymentRepository.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantPaymentRepository.cs index 2deaa91..fe07a6a 100644 --- a/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantPaymentRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantPaymentRepository.cs @@ -7,6 +7,24 @@ namespace TakeoutSaaS.Domain.Tenants.Repositories; /// public interface ITenantPaymentRepository { + /// + /// 分页查询支付记录。 + /// + /// 租户 ID。 + /// 开始时间(UTC)。 + /// 结束时间(UTC)。 + /// 页码(从 1 开始)。 + /// 每页条数。 + /// 取消标记。 + /// 分页结果(数据与总数)。 + Task<(IReadOnlyList Items, int Total)> SearchPagedAsync( + long tenantId, + DateTime from, + DateTime to, + int page, + int pageSize, + CancellationToken cancellationToken = default); + /// /// 查询指定账单的支付记录列表。 /// diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantVisibilityRoleRuleRepository.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantVisibilityRoleRuleRepository.cs new file mode 100644 index 0000000..d8e91c9 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantVisibilityRoleRuleRepository.cs @@ -0,0 +1,40 @@ +using TakeoutSaaS.Domain.Tenants.Entities; + +namespace TakeoutSaaS.Domain.Tenants.Repositories; + +/// +/// 租户可见角色规则仓储。 +/// +public interface ITenantVisibilityRoleRuleRepository +{ + /// + /// 按租户获取规则。 + /// + /// 租户 ID。 + /// 取消标记。 + /// 规则实体,未配置时返回 null。 + Task FindByTenantIdAsync(long tenantId, CancellationToken cancellationToken = default); + + /// + /// 新增规则。 + /// + /// 规则实体。 + /// 取消标记。 + /// 异步任务。 + Task AddAsync(TenantVisibilityRoleRule rule, CancellationToken cancellationToken = default); + + /// + /// 更新规则。 + /// + /// 规则实体。 + /// 取消标记。 + /// 异步任务。 + Task UpdateAsync(TenantVisibilityRoleRule rule, CancellationToken cancellationToken = default); + + /// + /// 保存变更。 + /// + /// 取消标记。 + /// 异步任务。 + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs index f3d6113..2fb0b1f 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs @@ -56,6 +56,7 @@ public static class AppServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/Repositories/TenantPaymentRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/Repositories/TenantPaymentRepository.cs index 9b20ecb..3258722 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/Repositories/TenantPaymentRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/Repositories/TenantPaymentRepository.cs @@ -11,6 +11,39 @@ namespace TakeoutSaaS.Infrastructure.App.Persistence.Repositories; /// public sealed class TenantPaymentRepository(TakeoutAppDbContext context) : ITenantPaymentRepository { + /// + public async Task<(IReadOnlyList Items, int Total)> SearchPagedAsync( + long tenantId, + DateTime from, + DateTime to, + int page, + int pageSize, + CancellationToken cancellationToken = default) + { + // 1. 归一化分页参数 + var normalizedPage = page <= 0 ? 1 : page; + var normalizedPageSize = pageSize <= 0 ? 20 : pageSize; + + // 2. 构建查询(按支付时间倒序) + var query = context.TenantPayments + .AsNoTracking() + .Where(x => x.DeletedAt == null + && x.TenantId == tenantId + && (x.PaidAt ?? x.CreatedAt) >= from + && (x.PaidAt ?? x.CreatedAt) <= to); + + // 3. 执行分页 + var total = await query.CountAsync(cancellationToken); + var items = await query + .OrderByDescending(x => x.PaidAt ?? x.CreatedAt) + .Skip((normalizedPage - 1) * normalizedPageSize) + .Take(normalizedPageSize) + .ToListAsync(cancellationToken); + + // 4. 返回分页结果 + return (items, total); + } + /// public async Task> GetByBillingIdAsync(long billingStatementId, CancellationToken cancellationToken = default) { diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/Repositories/TenantVisibilityRoleRuleRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/Repositories/TenantVisibilityRoleRuleRepository.cs new file mode 100644 index 0000000..398bf1c --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/Repositories/TenantVisibilityRoleRuleRepository.cs @@ -0,0 +1,58 @@ +using Microsoft.EntityFrameworkCore; +using TakeoutSaaS.Domain.Tenants.Entities; +using TakeoutSaaS.Domain.Tenants.Repositories; +using TakeoutSaaS.Infrastructure.App.Persistence; + +namespace TakeoutSaaS.Infrastructure.App.Persistence.Repositories; + +/// +/// 租户可见角色规则仓储实现。 +/// +public sealed class TenantVisibilityRoleRuleRepository(TakeoutAppDbContext context) : ITenantVisibilityRoleRuleRepository +{ + /// + /// 按租户获取规则。 + /// + /// 租户 ID。 + /// 取消标记。 + /// 规则实体或 null。 + public Task FindByTenantIdAsync(long tenantId, CancellationToken cancellationToken = default) + { + return context.TenantVisibilityRoleRules + .AsNoTracking() + .FirstOrDefaultAsync(x => x.TenantId == tenantId && x.DeletedAt == null, cancellationToken); + } + + /// + /// 新增规则。 + /// + /// 规则实体。 + /// 取消标记。 + /// 异步任务。 + public Task AddAsync(TenantVisibilityRoleRule rule, CancellationToken cancellationToken = default) + { + return context.TenantVisibilityRoleRules.AddAsync(rule, cancellationToken).AsTask(); + } + + /// + /// 更新规则。 + /// + /// 规则实体。 + /// 取消标记。 + /// 异步任务。 + public Task UpdateAsync(TenantVisibilityRoleRule rule, CancellationToken cancellationToken = default) + { + context.TenantVisibilityRoleRules.Update(rule); + return Task.CompletedTask; + } + + /// + /// 保存变更。 + /// + /// 取消标记。 + /// 异步任务。 + public Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + return context.SaveChangesAsync(cancellationToken); + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs index d4c44b6..f1c8126 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs @@ -88,6 +88,10 @@ public sealed class TakeoutAppDbContext( /// public DbSet TenantVerificationProfiles => Set(); /// + /// 租户账单/配额可见角色规则。 + /// + public DbSet TenantVisibilityRoleRules => Set(); + /// /// 配额包定义。 /// public DbSet QuotaPackages => Set(); @@ -394,6 +398,7 @@ public sealed class TakeoutAppDbContext( ConfigureTenantAnnouncement(modelBuilder.Entity()); ConfigureTenantAnnouncementRead(modelBuilder.Entity()); ConfigureTenantVerificationProfile(modelBuilder.Entity()); + ConfigureTenantVisibilityRoleRule(modelBuilder.Entity()); ConfigureQuotaPackage(modelBuilder.Entity()); ConfigureTenantQuotaPackagePurchase(modelBuilder.Entity()); ConfigureMerchantDocument(modelBuilder.Entity()); @@ -834,6 +839,18 @@ public sealed class TakeoutAppDbContext( builder.HasIndex(x => new { x.TenantId, x.Channel, x.SentAt }); } + private static void ConfigureTenantVisibilityRoleRule(EntityTypeBuilder builder) + { + builder.ToTable("tenant_visibility_role_rules"); + builder.HasKey(x => x.Id); + builder.Property(x => x.TenantId).IsRequired(); + builder.Property(x => x.QuotaVisibleRoleCodes).HasColumnType("text[]"); + builder.Property(x => x.BillingVisibleRoleCodes).HasColumnType("text[]"); + builder.Property(x => x.UpdatedBy).IsRequired(); + builder.Property(x => x.UpdatedAt).IsRequired(); + builder.HasIndex(x => x.TenantId).IsUnique(); + } + private static void ConfigureTenantAnnouncement(EntityTypeBuilder builder) { builder.ToTable("tenant_announcements"); diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Logs/Repositories/EfOperationLogRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Logs/Repositories/EfOperationLogRepository.cs index 21a567a..80b5643 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Logs/Repositories/EfOperationLogRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Logs/Repositories/EfOperationLogRepository.cs @@ -1,3 +1,4 @@ +using Microsoft.EntityFrameworkCore; using TakeoutSaaS.Domain.Tenants.Entities; using TakeoutSaaS.Domain.Tenants.Repositories; using TakeoutSaaS.Infrastructure.Logs.Persistence; @@ -9,6 +10,44 @@ namespace TakeoutSaaS.Infrastructure.Logs.Repositories; /// public sealed class EfOperationLogRepository(TakeoutLogsDbContext logsContext) : IOperationLogRepository { + /// + public async Task<(IReadOnlyList Items, int Total)> SearchByOperatorPagedAsync( + long tenantId, + string operatorId, + DateTime from, + DateTime to, + int page, + int pageSize, + CancellationToken cancellationToken = default) + { + // 1. 归一化参数 + var normalizedOperatorId = operatorId.Trim(); + var normalizedPage = page <= 0 ? 1 : page; + var normalizedPageSize = pageSize <= 0 ? 50 : pageSize; + + // 2. 构建查询(操作人 + 时间窗 + 租户约束) + var query = logsContext.OperationLogs + .AsNoTracking() + .Where(x => x.DeletedAt == null + && x.OperatorId == normalizedOperatorId + && x.CreatedAt >= from + && x.CreatedAt <= to + && x.Parameters != null + && (EF.Functions.ILike(x.Parameters, $"%\"tenantId\":{tenantId}%") + || EF.Functions.ILike(x.Parameters, $"%\"TenantId\":{tenantId}%"))); + + // 3. 查询总数与分页项 + var total = await query.CountAsync(cancellationToken); + var items = await query + .OrderByDescending(x => x.CreatedAt) + .Skip((normalizedPage - 1) * normalizedPageSize) + .Take(normalizedPageSize) + .ToListAsync(cancellationToken); + + // 4. 返回分页结果 + return (items, total); + } + /// public Task AddAsync(OperationLog log, CancellationToken cancellationToken = default) {