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)
{