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

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

9
.gitignore vendored
View File

@@ -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 目录提交

View File

@@ -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;
/// <summary>
/// 租户端个人中心。
/// </summary>
/// <remarks>
/// 提供个人总览、角色概览、配额、账单、支付、操作记录与消息摘要能力。
/// </remarks>
[ApiVersion("1.0")]
[Authorize]
[Produces("application/json")]
[Route("api/tenant/v{version:apiVersion}/personal")]
public sealed class PersonalController(IMediator mediator) : BaseApiController
{
/// <summary>
/// 获取个人中心总览。
/// </summary>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>总览结果。</returns>
[HttpGet("overview")]
[ProducesResponseType(typeof(ApiResponse<PersonalOverviewDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<PersonalOverviewDto>), StatusCodes.Status401Unauthorized)]
public async Task<ApiResponse<PersonalOverviewDto>> GetOverview(CancellationToken cancellationToken)
{
// 1. 查询总览
var overview = await mediator.Send(new GetPersonalOverviewQuery(), cancellationToken);
// 2. 返回结果
return ApiResponse<PersonalOverviewDto>.Ok(overview);
}
/// <summary>
/// 获取我的角色与权限概览。
/// </summary>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>角色权限概览。</returns>
[HttpGet("roles")]
[ProducesResponseType(typeof(ApiResponse<PersonalRolePermissionSummaryDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<PersonalRolePermissionSummaryDto>), StatusCodes.Status401Unauthorized)]
public async Task<ApiResponse<PersonalRolePermissionSummaryDto>> GetRoles(CancellationToken cancellationToken)
{
// 1. 查询角色权限概览
var summary = await mediator.Send(new GetPersonalRolesQuery(), cancellationToken);
// 2. 返回结果
return ApiResponse<PersonalRolePermissionSummaryDto>.Ok(summary);
}
/// <summary>
/// 获取套餐与配额摘要。
/// </summary>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>配额摘要。</returns>
[HttpGet("quota")]
[ProducesResponseType(typeof(ApiResponse<PersonalQuotaUsageSummaryDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<PersonalQuotaUsageSummaryDto>), StatusCodes.Status401Unauthorized)]
[ProducesResponseType(typeof(ApiResponse<PersonalQuotaUsageSummaryDto>), StatusCodes.Status403Forbidden)]
public async Task<ApiResponse<PersonalQuotaUsageSummaryDto>> GetQuota(CancellationToken cancellationToken)
{
// 1. 查询配额摘要
var summary = await mediator.Send(new GetPersonalQuotaQuery(), cancellationToken);
// 2. 返回结果
return ApiResponse<PersonalQuotaUsageSummaryDto>.Ok(summary);
}
/// <summary>
/// 分页查询账单记录。
/// </summary>
/// <param name="page">页码(从 1 开始)。</param>
/// <param name="pageSize">每页条数。</param>
/// <param name="from">开始时间UTC。</param>
/// <param name="to">结束时间UTC。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>账单分页结果。</returns>
[HttpGet("billing/statements")]
[ProducesResponseType(typeof(ApiResponse<PagedResult<PersonalBillingStatementDto>>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<PagedResult<PersonalBillingStatementDto>>), StatusCodes.Status401Unauthorized)]
[ProducesResponseType(typeof(ApiResponse<PagedResult<PersonalBillingStatementDto>>), StatusCodes.Status403Forbidden)]
[ProducesResponseType(typeof(ApiResponse<PagedResult<PersonalBillingStatementDto>>), StatusCodes.Status422UnprocessableEntity)]
public async Task<ApiResponse<PagedResult<PersonalBillingStatementDto>>> 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<PagedResult<PersonalBillingStatementDto>>.Ok(result);
}
/// <summary>
/// 分页查询支付记录。
/// </summary>
/// <param name="page">页码(从 1 开始)。</param>
/// <param name="pageSize">每页条数。</param>
/// <param name="from">开始时间UTC。</param>
/// <param name="to">结束时间UTC。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>支付记录分页结果。</returns>
[HttpGet("billing/payments")]
[ProducesResponseType(typeof(ApiResponse<PagedResult<PersonalPaymentRecordDto>>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<PagedResult<PersonalPaymentRecordDto>>), StatusCodes.Status401Unauthorized)]
[ProducesResponseType(typeof(ApiResponse<PagedResult<PersonalPaymentRecordDto>>), StatusCodes.Status403Forbidden)]
[ProducesResponseType(typeof(ApiResponse<PagedResult<PersonalPaymentRecordDto>>), StatusCodes.Status422UnprocessableEntity)]
public async Task<ApiResponse<PagedResult<PersonalPaymentRecordDto>>> 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<PagedResult<PersonalPaymentRecordDto>>.Ok(result);
}
/// <summary>
/// 获取账单/配额可见角色配置。
/// </summary>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>可见角色配置。</returns>
[HttpGet("visibility/roles")]
[ProducesResponseType(typeof(ApiResponse<PersonalVisibilityRoleConfigDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<PersonalVisibilityRoleConfigDto>), StatusCodes.Status401Unauthorized)]
[ProducesResponseType(typeof(ApiResponse<PersonalVisibilityRoleConfigDto>), StatusCodes.Status403Forbidden)]
public async Task<ApiResponse<PersonalVisibilityRoleConfigDto>> GetVisibilityRoles(CancellationToken cancellationToken)
{
// 1. 查询配置
var config = await mediator.Send(new GetPersonalVisibilityRoleConfigQuery(), cancellationToken);
// 2. 返回结果
return ApiResponse<PersonalVisibilityRoleConfigDto>.Ok(config);
}
/// <summary>
/// 更新账单/配额可见角色配置。
/// </summary>
/// <param name="command">更新请求。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>更新后的配置。</returns>
[HttpPut("visibility/roles")]
[ProducesResponseType(typeof(ApiResponse<PersonalVisibilityRoleConfigDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<PersonalVisibilityRoleConfigDto>), StatusCodes.Status401Unauthorized)]
[ProducesResponseType(typeof(ApiResponse<PersonalVisibilityRoleConfigDto>), StatusCodes.Status403Forbidden)]
[ProducesResponseType(typeof(ApiResponse<PersonalVisibilityRoleConfigDto>), StatusCodes.Status422UnprocessableEntity)]
public async Task<ApiResponse<PersonalVisibilityRoleConfigDto>> UpdateVisibilityRoles(
[FromBody] UpdatePersonalVisibilityRoleConfigCommand command,
CancellationToken cancellationToken)
{
// 1. 更新配置
var config = await mediator.Send(command, cancellationToken);
// 2. 返回结果
return ApiResponse<PersonalVisibilityRoleConfigDto>.Ok(config);
}
/// <summary>
/// 分页查询个人操作记录。
/// </summary>
/// <param name="page">页码(从 1 开始)。</param>
/// <param name="pageSize">每页条数(默认 50最大 50。</param>
/// <param name="from">开始时间UTC。</param>
/// <param name="to">结束时间UTC。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>操作记录分页结果。</returns>
[HttpGet("operations")]
[ProducesResponseType(typeof(ApiResponse<PagedResult<PersonalOperationLogDto>>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<PagedResult<PersonalOperationLogDto>>), StatusCodes.Status401Unauthorized)]
[ProducesResponseType(typeof(ApiResponse<PagedResult<PersonalOperationLogDto>>), StatusCodes.Status422UnprocessableEntity)]
public async Task<ApiResponse<PagedResult<PersonalOperationLogDto>>> 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<PagedResult<PersonalOperationLogDto>>.Ok(result);
}
/// <summary>
/// 分页查询个人消息摘要。
/// </summary>
/// <param name="page">页码(从 1 开始)。</param>
/// <param name="pageSize">每页条数。</param>
/// <param name="unreadOnly">是否仅返回未读。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>消息摘要分页结果。</returns>
[HttpGet("notifications")]
[ProducesResponseType(typeof(ApiResponse<PagedResult<PersonalNotificationDto>>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<PagedResult<PersonalNotificationDto>>), StatusCodes.Status401Unauthorized)]
[ProducesResponseType(typeof(ApiResponse<PagedResult<PersonalNotificationDto>>), StatusCodes.Status422UnprocessableEntity)]
public async Task<ApiResponse<PagedResult<PersonalNotificationDto>>> 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<PagedResult<PersonalNotificationDto>>.Ok(result);
}
}

View File

@@ -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<PersonalContextService>();
services.AddSingleton<PersonalMaskingService>();
services.AddSingleton<PersonalDateRangeValidator>();
services.AddScoped<PersonalModuleStatusService>();
services.AddScoped<PersonalAuditService>();
return services;
}
}

View File

@@ -0,0 +1,46 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.App.Personal.Dto;
/// <summary>
/// 个人中心账号信息 DTO。
/// </summary>
public sealed class PersonalAccountProfileDto
{
/// <summary>
/// 用户 ID雪花算法序列化为字符串
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long UserId { get; init; }
/// <summary>
/// 登录账号。
/// </summary>
public string Account { get; init; } = string.Empty;
/// <summary>
/// 展示名称。
/// </summary>
public string DisplayName { get; init; } = string.Empty;
/// <summary>
/// 头像地址。
/// </summary>
public string? AvatarUrl { get; init; }
/// <summary>
/// 脱敏手机号。
/// </summary>
public string? PhoneMasked { get; init; }
/// <summary>
/// 脱敏邮箱。
/// </summary>
public string? EmailMasked { get; init; }
/// <summary>
/// 注册时间UTC
/// </summary>
public DateTime RegisteredAt { get; init; }
}

View File

@@ -0,0 +1,46 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.App.Personal.Dto;
/// <summary>
/// 个人中心账单记录 DTO。
/// </summary>
public sealed class PersonalBillingStatementDto
{
/// <summary>
/// 账单 ID雪花算法序列化为字符串
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long StatementId { get; init; }
/// <summary>
/// 账单周期开始日期。
/// </summary>
public DateOnly BillingPeriodStart { get; init; }
/// <summary>
/// 账单周期结束日期。
/// </summary>
public DateOnly BillingPeriodEnd { get; init; }
/// <summary>
/// 应付金额。
/// </summary>
public decimal AmountDue { get; init; }
/// <summary>
/// 实付金额。
/// </summary>
public decimal AmountPaid { get; init; }
/// <summary>
/// 账单状态。
/// </summary>
public string Status { get; init; } = string.Empty;
/// <summary>
/// 到期时间UTC
/// </summary>
public DateTime DueAt { get; init; }
}

View File

@@ -0,0 +1,32 @@
namespace TakeoutSaaS.Application.App.Personal.Dto;
/// <summary>
/// 个人中心子模块执行状态 DTO。
/// </summary>
public sealed class PersonalModuleStatusDto
{
/// <summary>
/// 模块名称。
/// </summary>
public string Module { get; init; } = string.Empty;
/// <summary>
/// 模块状态ok / degraded / failed / timeout / skipped。
/// </summary>
public string Status { get; init; } = "ok";
/// <summary>
/// 业务错误码。
/// </summary>
public string? ErrorCode { get; init; }
/// <summary>
/// 错误说明。
/// </summary>
public string? ErrorMessage { get; init; }
/// <summary>
/// 追踪标识。
/// </summary>
public string? TraceId { get; init; }
}

View File

@@ -0,0 +1,36 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.App.Personal.Dto;
/// <summary>
/// 个人中心消息摘要 DTO。
/// </summary>
public sealed class PersonalNotificationDto
{
/// <summary>
/// 通知 ID雪花算法序列化为字符串
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long NotificationId { get; init; }
/// <summary>
/// 标题。
/// </summary>
public string Title { get; init; } = string.Empty;
/// <summary>
/// 类别。
/// </summary>
public string Category { get; init; } = string.Empty;
/// <summary>
/// 是否已读。
/// </summary>
public bool IsRead { get; init; }
/// <summary>
/// 发送时间UTC
/// </summary>
public DateTime SentAt { get; init; }
}

View File

@@ -0,0 +1,47 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.App.Personal.Dto;
/// <summary>
/// 个人中心操作记录 DTO。
/// </summary>
public sealed class PersonalOperationLogDto
{
/// <summary>
/// 操作记录 ID雪花算法序列化为字符串
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long OperationId { get; init; }
/// <summary>
/// 操作人用户 ID雪花算法序列化为字符串
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long OperatorUserId { get; init; }
/// <summary>
/// 操作类型。
/// </summary>
public string ActionType { get; init; } = string.Empty;
/// <summary>
/// 目标类型。
/// </summary>
public string TargetType { get; init; } = string.Empty;
/// <summary>
/// 目标 ID。
/// </summary>
public string? TargetId { get; init; }
/// <summary>
/// 是否成功。
/// </summary>
public bool IsSuccess { get; init; }
/// <summary>
/// 发生时间UTC
/// </summary>
public DateTime OccurredAt { get; init; }
}

View File

@@ -0,0 +1,47 @@
namespace TakeoutSaaS.Application.App.Personal.Dto;
/// <summary>
/// 个人中心总览 DTO。
/// </summary>
public sealed class PersonalOverviewDto
{
/// <summary>
/// 请求追踪标识。
/// </summary>
public string RequestId { get; init; } = string.Empty;
/// <summary>
/// 总体状态success / partial_success / failure。
/// </summary>
public string OverallStatus { get; init; } = "success";
/// <summary>
/// 账号信息。
/// </summary>
public PersonalAccountProfileDto? AccountProfile { get; init; }
/// <summary>
/// 安全信息。
/// </summary>
public PersonalSecuritySnapshotDto? SecuritySnapshot { get; init; }
/// <summary>
/// 角色权限概览。
/// </summary>
public PersonalRolePermissionSummaryDto? RoleSummary { get; init; }
/// <summary>
/// 归属信息。
/// </summary>
public PersonalTenantAffiliationDto? TenantAffiliation { get; init; }
/// <summary>
/// 配额摘要(可选)。
/// </summary>
public PersonalQuotaUsageSummaryDto? QuotaSummary { get; init; }
/// <summary>
/// 子模块执行状态。
/// </summary>
public IReadOnlyList<PersonalModuleStatusDto> ModuleStatuses { get; init; } = [];
}

View File

@@ -0,0 +1,59 @@
namespace TakeoutSaaS.Application.App.Personal.Dto;
/// <summary>
/// 个人中心通用分页查询契约。
/// </summary>
public sealed record PersonalPagedQuery
{
/// <summary>
/// 页码,从 1 开始。
/// </summary>
public int Page { get; init; } = 1;
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; init; } = 20;
/// <summary>
/// 查询开始时间UTC
/// </summary>
public DateTime? From { get; init; }
/// <summary>
/// 查询结束时间UTC
/// </summary>
public DateTime? To { get; init; }
/// <summary>
/// 获取有效页码。
/// </summary>
/// <returns>最小为 1 的页码。</returns>
public int ResolvePage()
{
// 1. 对非法页码做兜底
return Page < 1 ? 1 : Page;
}
/// <summary>
/// 获取有效页大小。
/// </summary>
/// <param name="defaultPageSize">默认页大小。</param>
/// <param name="maxPageSize">最大页大小。</param>
/// <returns>落在约束范围内的页大小。</returns>
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;
}
}

View File

@@ -0,0 +1,42 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.App.Personal.Dto;
/// <summary>
/// 个人中心支付记录 DTO。
/// </summary>
public sealed class PersonalPaymentRecordDto
{
/// <summary>
/// 支付记录 ID雪花算法序列化为字符串
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long PaymentId { get; init; }
/// <summary>
/// 关联账单 ID雪花算法序列化为字符串
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long StatementId { get; init; }
/// <summary>
/// 支付金额。
/// </summary>
public decimal PaidAmount { get; init; }
/// <summary>
/// 支付方式。
/// </summary>
public string PaymentMethod { get; init; } = string.Empty;
/// <summary>
/// 支付状态。
/// </summary>
public string PaymentStatus { get; init; } = string.Empty;
/// <summary>
/// 支付时间UTC
/// </summary>
public DateTime? PaidAt { get; init; }
}

View File

@@ -0,0 +1,37 @@
namespace TakeoutSaaS.Application.App.Personal.Dto;
/// <summary>
/// 个人中心配额项 DTO。
/// </summary>
public sealed class PersonalQuotaUsageItemDto
{
/// <summary>
/// 配额编码。
/// </summary>
public string QuotaCode { get; init; } = string.Empty;
/// <summary>
/// 配额名称。
/// </summary>
public string QuotaName { get; init; } = string.Empty;
/// <summary>
/// 配额上限值。
/// </summary>
public decimal LimitValue { get; init; }
/// <summary>
/// 已用值。
/// </summary>
public decimal UsedValue { get; init; }
/// <summary>
/// 单位。
/// </summary>
public string Unit { get; init; } = string.Empty;
/// <summary>
/// 使用比例。
/// </summary>
public decimal UsageRatio { get; init; }
}

View File

@@ -0,0 +1,12 @@
namespace TakeoutSaaS.Application.App.Personal.Dto;
/// <summary>
/// 个人中心配额摘要 DTO。
/// </summary>
public sealed class PersonalQuotaUsageSummaryDto
{
/// <summary>
/// 配额项集合。
/// </summary>
public IReadOnlyList<PersonalQuotaUsageItemDto> Items { get; init; } = [];
}

View File

@@ -0,0 +1,17 @@
namespace TakeoutSaaS.Application.App.Personal.Dto;
/// <summary>
/// 个人中心角色项 DTO。
/// </summary>
public sealed class PersonalRoleItemDto
{
/// <summary>
/// 角色编码。
/// </summary>
public string RoleCode { get; init; } = string.Empty;
/// <summary>
/// 角色名称。
/// </summary>
public string RoleName { get; init; } = string.Empty;
}

View File

@@ -0,0 +1,17 @@
namespace TakeoutSaaS.Application.App.Personal.Dto;
/// <summary>
/// 个人中心角色与权限概览 DTO。
/// </summary>
public sealed class PersonalRolePermissionSummaryDto
{
/// <summary>
/// 角色集合。
/// </summary>
public IReadOnlyList<PersonalRoleItemDto> Roles { get; init; } = [];
/// <summary>
/// 权限数量。
/// </summary>
public int PermissionCount { get; init; }
}

View File

@@ -0,0 +1,32 @@
namespace TakeoutSaaS.Application.App.Personal.Dto;
/// <summary>
/// 个人中心安全状态快照 DTO。
/// </summary>
public sealed class PersonalSecuritySnapshotDto
{
/// <summary>
/// 最近登录时间UTC
/// </summary>
public DateTime? LastLoginAt { get; init; }
/// <summary>
/// 失败登录次数。
/// </summary>
public int FailedLoginCount { get; init; }
/// <summary>
/// 是否已锁定。
/// </summary>
public bool IsLocked { get; init; }
/// <summary>
/// 锁定截止时间UTC
/// </summary>
public DateTime? LockedUntil { get; init; }
/// <summary>
/// 是否强制修改密码。
/// </summary>
public bool IsForceChangePassword { get; init; }
}

View File

@@ -0,0 +1,47 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.App.Personal.Dto;
/// <summary>
/// 个人中心租户归属信息 DTO。
/// </summary>
public sealed class PersonalTenantAffiliationDto
{
/// <summary>
/// 租户 ID雪花算法序列化为字符串
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long TenantId { get; init; }
/// <summary>
/// 租户名称。
/// </summary>
public string TenantName { get; init; } = string.Empty;
/// <summary>
/// 商户 ID雪花算法序列化为字符串
/// </summary>
[JsonConverter(typeof(NullableSnowflakeIdJsonConverter))]
public long? MerchantId { get; init; }
/// <summary>
/// 商户名称。
/// </summary>
public string? MerchantName { get; init; }
/// <summary>
/// 商户状态。
/// </summary>
public string MerchantStatus { get; init; } = string.Empty;
/// <summary>
/// 当前套餐名称。
/// </summary>
public string? PackageName { get; init; }
/// <summary>
/// 订阅到期时间UTC
/// </summary>
public DateTime? SubscriptionExpireAt { get; init; }
}

View File

@@ -0,0 +1,37 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.App.Personal.Dto;
/// <summary>
/// 个人中心账单/配额可见角色配置 DTO。
/// </summary>
public sealed class PersonalVisibilityRoleConfigDto
{
/// <summary>
/// 租户 ID雪花算法序列化为字符串
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long TenantId { get; init; }
/// <summary>
/// 配额可见角色编码集合。
/// </summary>
public IReadOnlyList<string> QuotaVisibleRoleCodes { get; init; } = [];
/// <summary>
/// 账单可见角色编码集合。
/// </summary>
public IReadOnlyList<string> BillingVisibleRoleCodes { get; init; } = [];
/// <summary>
/// 最近更新人(雪花算法,序列化为字符串)。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long UpdatedBy { get; init; }
/// <summary>
/// 最近更新时间UTC
/// </summary>
public DateTime UpdatedAt { get; init; }
}

View File

@@ -0,0 +1,145 @@
using MediatR;
using TakeoutSaaS.Application.App.Personal.Dto;
using TakeoutSaaS.Application.App.Personal.Queries;
using TakeoutSaaS.Application.App.Personal.Services;
using TakeoutSaaS.Domain.Identity.Enums;
using TakeoutSaaS.Domain.Identity.Repositories;
using TakeoutSaaS.Domain.Merchants.Repositories;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
namespace TakeoutSaaS.Application.App.Personal.Handlers;
/// <summary>
/// 获取个人中心总览处理器。
/// </summary>
public sealed class GetPersonalOverviewQueryHandler(
PersonalContextService personalContextService,
PersonalMaskingService personalMaskingService,
PersonalModuleStatusService moduleStatusService,
IIdentityUserRepository identityUserRepository,
ITenantRepository tenantRepository,
ITenantPackageRepository tenantPackageRepository,
IMerchantRepository merchantRepository,
IMediator mediator)
: IRequestHandler<GetPersonalOverviewQuery, PersonalOverviewDto>
{
/// <summary>
/// 处理查询。
/// </summary>
/// <param name="request">查询请求。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>总览结果。</returns>
public async Task<PersonalOverviewDto> Handle(GetPersonalOverviewQuery request, CancellationToken cancellationToken)
{
// 1. 获取必需上下文
var context = personalContextService.GetRequiredContext();
var traceId = context.TraceId;
// 2. 初始化总览容器
var moduleStatuses = new List<PersonalModuleStatusDto>();
PersonalAccountProfileDto? accountProfile = null;
PersonalSecuritySnapshotDto? securitySnapshot = null;
PersonalRolePermissionSummaryDto? roleSummary = null;
PersonalTenantAffiliationDto? tenantAffiliation = null;
// 3. 加载账号与安全模块
var user = await identityUserRepository.FindByIdAsync(context.UserId, cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "用户不存在");
if (user.TenantId != context.TenantId)
{
throw new BusinessException(ErrorCodes.Forbidden, "无权访问其他租户用户数据");
}
accountProfile = new PersonalAccountProfileDto
{
UserId = user.Id,
Account = user.Account,
DisplayName = user.DisplayName,
AvatarUrl = user.Avatar,
PhoneMasked = personalMaskingService.MaskPhone(user.Phone),
EmailMasked = personalMaskingService.MaskEmail(user.Email),
RegisteredAt = user.CreatedAt
};
securitySnapshot = new PersonalSecuritySnapshotDto
{
LastLoginAt = user.LastLoginAt,
FailedLoginCount = user.FailedLoginCount,
IsLocked = user.Status == IdentityUserStatus.Locked || (user.LockedUntil.HasValue && user.LockedUntil > DateTime.UtcNow),
LockedUntil = user.LockedUntil,
IsForceChangePassword = user.MustChangePassword
};
moduleStatuses.Add(moduleStatusService.BuildOk("accountSecurity", traceId));
// 4. 加载角色权限模块(失败可降级)
try
{
roleSummary = await mediator.Send(new GetPersonalRolesQuery(), cancellationToken);
moduleStatuses.Add(moduleStatusService.BuildOk("roleSummary", traceId));
}
catch (Exception ex)
{
moduleStatuses.Add(moduleStatusService.BuildIssue("roleSummary", "degraded", ErrorCodes.InternalServerError.ToString(), ex.Message, traceId));
}
// 5. 加载租户归属模块(失败可降级)
try
{
var tenant = await tenantRepository.FindByIdAsync(context.TenantId, cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "租户不存在");
var subscription = await tenantRepository.GetActiveSubscriptionAsync(context.TenantId, cancellationToken);
string? packageName = null;
if (subscription is not null)
{
var package = await tenantPackageRepository.FindByIdAsync(subscription.TenantPackageId, cancellationToken);
packageName = package?.Name;
}
string? merchantName = null;
string merchantStatus = "unknown";
if (user.MerchantId is > 0)
{
var merchant = await merchantRepository.FindByIdAsync(user.MerchantId.Value, context.TenantId, cancellationToken);
merchantName = merchant?.BrandName;
merchantStatus = merchant?.Status.ToString().ToLowerInvariant() ?? "unknown";
}
tenantAffiliation = new PersonalTenantAffiliationDto
{
TenantId = tenant.Id,
TenantName = tenant.Name,
MerchantId = user.MerchantId,
MerchantName = merchantName,
MerchantStatus = merchantStatus,
PackageName = packageName,
SubscriptionExpireAt = subscription?.EffectiveTo ?? tenant.EffectiveTo
};
moduleStatuses.Add(moduleStatusService.BuildOk("tenantAffiliation", traceId));
}
catch (Exception ex)
{
moduleStatuses.Add(moduleStatusService.BuildIssue("tenantAffiliation", "degraded", ErrorCodes.InternalServerError.ToString(), ex.Message, traceId));
}
// 6. 计算总体状态并返回
var hasAnyData = accountProfile is not null
|| securitySnapshot is not null
|| roleSummary is not null
|| tenantAffiliation is not null;
var requestId = string.IsNullOrWhiteSpace(traceId) ? Guid.NewGuid().ToString("N") : traceId;
var overallStatus = moduleStatusService.ResolveOverallStatus(moduleStatuses, hasAnyData);
return new PersonalOverviewDto
{
RequestId = requestId,
OverallStatus = overallStatus,
AccountProfile = accountProfile,
SecuritySnapshot = securitySnapshot,
RoleSummary = roleSummary,
TenantAffiliation = tenantAffiliation,
QuotaSummary = null,
ModuleStatuses = moduleStatuses
};
}
}

View File

@@ -0,0 +1,109 @@
using MediatR;
using TakeoutSaaS.Application.App.Personal.Dto;
using TakeoutSaaS.Application.App.Personal.Queries;
using TakeoutSaaS.Application.App.Personal.Services;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
namespace TakeoutSaaS.Application.App.Personal.Handlers;
/// <summary>
/// 获取个人中心配额摘要处理器。
/// </summary>
public sealed class GetPersonalQuotaQueryHandler(
PersonalContextService personalContextService,
PersonalAuditService personalAuditService,
ITenantQuotaUsageRepository tenantQuotaUsageRepository,
ITenantVisibilityRoleRuleRepository visibilityRoleRuleRepository)
: IRequestHandler<GetPersonalQuotaQuery, PersonalQuotaUsageSummaryDto>
{
private static readonly string[] DefaultQuotaVisibleRoles = ["tenant-owner", "tenant-admin"];
/// <summary>
/// 处理查询。
/// </summary>
/// <param name="request">查询请求。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>配额摘要。</returns>
public async Task<PersonalQuotaUsageSummaryDto> Handle(GetPersonalQuotaQuery request, CancellationToken cancellationToken)
{
try
{
// 1. 获取请求上下文
var context = personalContextService.GetRequiredContext();
// 2. 校验可见角色权限
var rule = await visibilityRoleRuleRepository.FindByTenantIdAsync(context.TenantId, cancellationToken);
var allowedRoles = rule?.QuotaVisibleRoleCodes?.Length > 0
? rule.QuotaVisibleRoleCodes
: DefaultQuotaVisibleRoles;
if (!HasAnyRole(context.RoleCodes, allowedRoles))
{
throw new BusinessException(ErrorCodes.Forbidden, "当前角色无权访问配额数据");
}
// 3. 查询配额并组装摘要
var usages = await tenantQuotaUsageRepository.GetByTenantAsync(context.TenantId, cancellationToken);
var items = usages
.OrderBy(x => x.QuotaType)
.Select(MapQuotaItem)
.ToList();
// 4. 记录审计并返回
await personalAuditService.RecordSensitiveQueryAsync("quota", true, "查询配额摘要成功", cancellationToken);
return new PersonalQuotaUsageSummaryDto
{
Items = items
};
}
catch (Exception ex)
{
// 5. 记录失败审计并继续抛出
await personalAuditService.RecordSensitiveQueryAsync("quota", false, ex.Message, cancellationToken);
throw;
}
}
private static PersonalQuotaUsageItemDto MapQuotaItem(TakeoutSaaS.Domain.Tenants.Entities.TenantQuotaUsage usage)
{
// 1. 映射元数据
var metadata = usage.QuotaType switch
{
TenantQuotaType.StoreCount => ("store_count", "门店数量", "个"),
TenantQuotaType.AccountCount => ("account_count", "账号数量", "个"),
TenantQuotaType.Storage => ("storage", "存储空间", "GB"),
TenantQuotaType.SmsCredits => ("sms_credits", "短信额度", "条"),
TenantQuotaType.DeliveryOrders => ("delivery_orders", "配送订单", "单"),
TenantQuotaType.PromotionSlots => ("promotion_slots", "营销位", "个"),
_ => (usage.QuotaType.ToString().ToLowerInvariant(), usage.QuotaType.ToString(), string.Empty)
};
// 2. 计算使用率
var ratio = usage.LimitValue <= 0 ? 0 : Math.Round(usage.UsedValue / usage.LimitValue, 4);
// 3. 返回 DTO
return new PersonalQuotaUsageItemDto
{
QuotaCode = metadata.Item1,
QuotaName = metadata.Item2,
LimitValue = usage.LimitValue,
UsedValue = usage.UsedValue,
Unit = metadata.Item3,
UsageRatio = ratio
};
}
private static bool HasAnyRole(IReadOnlyList<string> currentRoles, IReadOnlyList<string> allowedRoles)
{
// 1. 构建角色集合并执行交集判断
var current = currentRoles
.Where(static x => !string.IsNullOrWhiteSpace(x))
.Select(static x => x.Trim())
.ToHashSet(StringComparer.OrdinalIgnoreCase);
// 2. 返回是否命中任一允许角色
return allowedRoles.Any(current.Contains);
}
}

View File

@@ -0,0 +1,67 @@
using MediatR;
using TakeoutSaaS.Application.App.Personal.Dto;
using TakeoutSaaS.Application.App.Personal.Queries;
using TakeoutSaaS.Application.App.Personal.Services;
using TakeoutSaaS.Domain.Identity.Repositories;
namespace TakeoutSaaS.Application.App.Personal.Handlers;
/// <summary>
/// 获取个人角色与权限概览处理器。
/// </summary>
public sealed class GetPersonalRolesQueryHandler(
PersonalContextService personalContextService,
IUserRoleRepository userRoleRepository,
IRoleRepository roleRepository,
IRolePermissionRepository rolePermissionRepository)
: IRequestHandler<GetPersonalRolesQuery, PersonalRolePermissionSummaryDto>
{
/// <summary>
/// 处理查询。
/// </summary>
/// <param name="request">查询请求。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>角色权限概览。</returns>
public async Task<PersonalRolePermissionSummaryDto> Handle(GetPersonalRolesQuery request, CancellationToken cancellationToken)
{
// 1. 获取请求上下文
var context = personalContextService.GetRequiredContext();
// 2. 获取用户角色关系
var userRoles = await userRoleRepository.GetByUserIdAsync(context.TenantId, context.UserId, cancellationToken);
var roleIds = userRoles.Select(static x => x.RoleId).Distinct().ToArray();
if (roleIds.Length == 0)
{
return new PersonalRolePermissionSummaryDto
{
Roles = [],
PermissionCount = 0
};
}
// 3. 获取角色明细
var roles = await roleRepository.GetByIdsAsync(context.TenantId, roleIds, cancellationToken);
var roleItems = roles
.OrderBy(static x => x.Name)
.Select(static x => new PersonalRoleItemDto
{
RoleCode = x.Code,
RoleName = x.Name
})
.ToList();
// 4. 统计去重权限数量
var rolePermissions = await rolePermissionRepository.GetByRoleIdsAsync(context.TenantId, roleIds, cancellationToken);
var permissionCount = rolePermissions
.Select(static x => x.PermissionId)
.Distinct()
.Count();
// 5. 返回结果
return new PersonalRolePermissionSummaryDto
{
Roles = roleItems,
PermissionCount = permissionCount
};
}
}

View File

@@ -0,0 +1,72 @@
using MediatR;
using TakeoutSaaS.Application.App.Personal.Dto;
using TakeoutSaaS.Application.App.Personal.Queries;
using TakeoutSaaS.Application.App.Personal.Services;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
namespace TakeoutSaaS.Application.App.Personal.Handlers;
/// <summary>
/// 获取个人中心可见角色配置处理器。
/// </summary>
public sealed class GetPersonalVisibilityRoleConfigQueryHandler(
PersonalContextService personalContextService,
ITenantVisibilityRoleRuleRepository visibilityRoleRuleRepository)
: IRequestHandler<GetPersonalVisibilityRoleConfigQuery, PersonalVisibilityRoleConfigDto>
{
private static readonly string[] ManagerRoleCodes = ["tenant-owner", "tenant-admin"];
private static readonly string[] DefaultVisibleRoleCodes = ["tenant-owner", "tenant-admin"];
/// <summary>
/// 处理查询。
/// </summary>
/// <param name="request">查询请求。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>可见角色配置。</returns>
public async Task<PersonalVisibilityRoleConfigDto> Handle(GetPersonalVisibilityRoleConfigQuery request, CancellationToken cancellationToken)
{
// 1. 获取上下文并校验管理权限
var context = personalContextService.GetRequiredContext();
if (!HasAnyRole(context.RoleCodes, ManagerRoleCodes))
{
throw new BusinessException(ErrorCodes.Forbidden, "当前角色无权查看可见角色配置");
}
// 2. 查询并返回规则
var rule = await visibilityRoleRuleRepository.FindByTenantIdAsync(context.TenantId, cancellationToken);
if (rule is null)
{
return new PersonalVisibilityRoleConfigDto
{
TenantId = context.TenantId,
QuotaVisibleRoleCodes = DefaultVisibleRoleCodes,
BillingVisibleRoleCodes = DefaultVisibleRoleCodes,
UpdatedBy = context.UserId,
UpdatedAt = DateTime.UtcNow
};
}
return new PersonalVisibilityRoleConfigDto
{
TenantId = rule.TenantId,
QuotaVisibleRoleCodes = rule.QuotaVisibleRoleCodes,
BillingVisibleRoleCodes = rule.BillingVisibleRoleCodes,
UpdatedBy = rule.UpdatedBy ?? context.UserId,
UpdatedAt = rule.UpdatedAt ?? DateTime.UtcNow
};
}
private static bool HasAnyRole(IReadOnlyList<string> currentRoles, IReadOnlyList<string> allowedRoles)
{
// 1. 归一化当前角色
var roleSet = currentRoles
.Where(static x => !string.IsNullOrWhiteSpace(x))
.Select(static x => x.Trim())
.ToHashSet(StringComparer.OrdinalIgnoreCase);
// 2. 判断是否命中允许角色
return allowedRoles.Any(roleSet.Contains);
}
}

View File

@@ -0,0 +1,108 @@
using MediatR;
using TakeoutSaaS.Application.App.Personal.Dto;
using TakeoutSaaS.Application.App.Personal.Queries;
using TakeoutSaaS.Application.App.Personal.Services;
using TakeoutSaaS.Application.App.Personal.Validators;
using TakeoutSaaS.Domain.Tenants.Entities;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Results;
namespace TakeoutSaaS.Application.App.Personal.Handlers;
/// <summary>
/// 分页查询个人中心账单记录处理器。
/// </summary>
public sealed class SearchPersonalBillingStatementsQueryHandler(
PersonalContextService personalContextService,
PersonalDateRangeValidator personalDateRangeValidator,
PersonalAuditService personalAuditService,
ITenantBillingRepository tenantBillingRepository,
ITenantVisibilityRoleRuleRepository visibilityRoleRuleRepository)
: IRequestHandler<SearchPersonalBillingStatementsQuery, PagedResult<PersonalBillingStatementDto>>
{
private static readonly string[] DefaultBillingVisibleRoles = ["tenant-owner", "tenant-admin"];
/// <summary>
/// 处理查询。
/// </summary>
/// <param name="request">查询请求。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>账单分页结果。</returns>
public async Task<PagedResult<PersonalBillingStatementDto>> Handle(SearchPersonalBillingStatementsQuery request, CancellationToken cancellationToken)
{
try
{
// 1. 获取上下文并校验可见角色
var context = personalContextService.GetRequiredContext();
var rule = await visibilityRoleRuleRepository.FindByTenantIdAsync(context.TenantId, cancellationToken);
var allowedRoles = rule?.BillingVisibleRoleCodes?.Length > 0
? rule.BillingVisibleRoleCodes
: DefaultBillingVisibleRoles;
if (!HasAnyRole(context.RoleCodes, allowedRoles))
{
throw new BusinessException(ErrorCodes.Forbidden, "当前角色无权访问账单数据");
}
// 2. 解析时间窗与分页参数
var dateRange = personalDateRangeValidator.Resolve(request.From, request.To);
var page = request.Page <= 0 ? 1 : request.Page;
var pageSize = request.PageSize <= 0 ? 20 : Math.Min(request.PageSize, 50);
// 3. 查询并分页
var statements = await tenantBillingRepository.SearchAsync(context.TenantId, null, dateRange.From, dateRange.To, cancellationToken);
var ordered = statements.OrderByDescending(static x => x.PeriodEnd).ToList();
var items = ordered
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(MapBilling)
.ToList();
// 4. 记录审计并返回
await personalAuditService.RecordSensitiveQueryAsync("billing-statements", true, "查询账单成功", cancellationToken);
return new PagedResult<PersonalBillingStatementDto>(items, page, pageSize, ordered.Count);
}
catch (Exception ex)
{
// 5. 记录失败审计并继续抛出
await personalAuditService.RecordSensitiveQueryAsync("billing-statements", false, ex.Message, cancellationToken);
throw;
}
}
private static PersonalBillingStatementDto MapBilling(TenantBillingStatement billing)
{
// 1. 映射账单状态
var status = billing.Status switch
{
TenantBillingStatus.Pending when billing.AmountPaid > 0 => "partial_paid",
_ => billing.Status.ToString().ToLowerInvariant()
};
// 2. 返回 DTO
return new PersonalBillingStatementDto
{
StatementId = billing.Id,
BillingPeriodStart = DateOnly.FromDateTime(billing.PeriodStart),
BillingPeriodEnd = DateOnly.FromDateTime(billing.PeriodEnd),
AmountDue = billing.AmountDue,
AmountPaid = billing.AmountPaid,
Status = status,
DueAt = billing.DueDate
};
}
private static bool HasAnyRole(IReadOnlyList<string> currentRoles, IReadOnlyList<string> allowedRoles)
{
// 1. 归一化当前角色
var roleSet = currentRoles
.Where(static x => !string.IsNullOrWhiteSpace(x))
.Select(static x => x.Trim())
.ToHashSet(StringComparer.OrdinalIgnoreCase);
// 2. 命中任一允许角色即通过
return allowedRoles.Any(roleSet.Contains);
}
}

View File

@@ -0,0 +1,58 @@
using MediatR;
using TakeoutSaaS.Application.App.Personal.Dto;
using TakeoutSaaS.Application.App.Personal.Queries;
using TakeoutSaaS.Application.App.Personal.Services;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Results;
namespace TakeoutSaaS.Application.App.Personal.Handlers;
/// <summary>
/// 分页查询个人消息摘要处理器。
/// </summary>
public sealed class SearchPersonalNotificationsQueryHandler(
PersonalContextService personalContextService,
ITenantNotificationRepository tenantNotificationRepository)
: IRequestHandler<SearchPersonalNotificationsQuery, PagedResult<PersonalNotificationDto>>
{
/// <summary>
/// 处理查询。
/// </summary>
/// <param name="request">查询请求。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>消息摘要分页结果。</returns>
public async Task<PagedResult<PersonalNotificationDto>> Handle(SearchPersonalNotificationsQuery request, CancellationToken cancellationToken)
{
// 1. 获取上下文并解析分页
var context = personalContextService.GetRequiredContext();
var page = request.Page <= 0 ? 1 : request.Page;
var pageSize = request.PageSize <= 0 ? 20 : Math.Min(request.PageSize, 50);
// 2. 查询通知
var notifications = await tenantNotificationRepository.SearchAsync(
context.TenantId,
severity: null,
unreadOnly: request.UnreadOnly,
from: null,
to: null,
cancellationToken);
// 3. 排序分页并映射
var ordered = notifications.OrderByDescending(static x => x.SentAt).ToList();
var items = ordered
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(static x => new PersonalNotificationDto
{
NotificationId = x.Id,
Title = x.Title,
Category = x.Severity.ToString().ToLowerInvariant(),
IsRead = x.ReadAt.HasValue,
SentAt = x.SentAt
})
.ToList();
// 4. 返回结果
return new PagedResult<PersonalNotificationDto>(items, page, pageSize, ordered.Count);
}
}

View File

@@ -0,0 +1,102 @@
using MediatR;
using System.Text.Json;
using TakeoutSaaS.Application.App.Personal.Dto;
using TakeoutSaaS.Application.App.Personal.Queries;
using TakeoutSaaS.Application.App.Personal.Services;
using TakeoutSaaS.Application.App.Personal.Validators;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Results;
namespace TakeoutSaaS.Application.App.Personal.Handlers;
/// <summary>
/// 分页查询个人操作记录处理器。
/// </summary>
public sealed class SearchPersonalOperationsQueryHandler(
PersonalContextService personalContextService,
PersonalDateRangeValidator personalDateRangeValidator,
IOperationLogRepository operationLogRepository)
: IRequestHandler<SearchPersonalOperationsQuery, PagedResult<PersonalOperationLogDto>>
{
/// <summary>
/// 处理查询。
/// </summary>
/// <param name="request">查询请求。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>操作记录分页结果。</returns>
public async Task<PagedResult<PersonalOperationLogDto>> Handle(SearchPersonalOperationsQuery request, CancellationToken cancellationToken)
{
// 1. 获取上下文并解析时间窗
var context = personalContextService.GetRequiredContext();
var dateRange = personalDateRangeValidator.Resolve(request.From, request.To);
// 2. 解析分页参数(默认 50上限 50
var page = request.Page <= 0 ? 1 : request.Page;
var pageSize = request.PageSize <= 0 ? 50 : Math.Min(request.PageSize, 50);
// 3. 查询并映射
var paged = await operationLogRepository.SearchByOperatorPagedAsync(
context.TenantId,
context.UserId.ToString(),
dateRange.From,
dateRange.To,
page,
pageSize,
cancellationToken);
var items = paged.Items.Select(log => new PersonalOperationLogDto
{
OperationId = log.Id,
OperatorUserId = ParseOperatorUserId(log.OperatorId, context.UserId),
ActionType = log.OperationType,
TargetType = log.TargetType,
TargetId = ResolveTargetId(log.TargetIds),
IsSuccess = log.Success,
OccurredAt = log.CreatedAt
}).ToList();
// 4. 返回分页结果
return new PagedResult<PersonalOperationLogDto>(items, page, pageSize, paged.Total);
}
private static long ParseOperatorUserId(string? operatorId, long fallbackUserId)
{
// 1. 尝试从字符串解析用户 ID
if (long.TryParse(operatorId, out var parsed) && parsed > 0)
{
return parsed;
}
// 2. 解析失败时回退当前用户
return fallbackUserId;
}
private static string? ResolveTargetId(string? targetIds)
{
// 1. 空值直接返回
if (string.IsNullOrWhiteSpace(targetIds))
{
return null;
}
// 2. 尝试解析 JSON 数组并提取首个元素
var normalized = targetIds.Trim();
if (normalized.StartsWith("[", StringComparison.Ordinal))
{
try
{
using var doc = JsonDocument.Parse(normalized);
if (doc.RootElement.ValueKind == JsonValueKind.Array && doc.RootElement.GetArrayLength() > 0)
{
return doc.RootElement[0].ToString();
}
}
catch (JsonException)
{
// 3. JSON 解析失败时按原始字符串返回
}
}
// 4. 非数组场景直接返回原值
return normalized;
}
}

View File

@@ -0,0 +1,117 @@
using MediatR;
using TakeoutSaaS.Application.App.Personal.Dto;
using TakeoutSaaS.Application.App.Personal.Queries;
using TakeoutSaaS.Application.App.Personal.Services;
using TakeoutSaaS.Application.App.Personal.Validators;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Results;
namespace TakeoutSaaS.Application.App.Personal.Handlers;
/// <summary>
/// 分页查询个人中心支付记录处理器。
/// </summary>
public sealed class SearchPersonalPaymentsQueryHandler(
PersonalContextService personalContextService,
PersonalDateRangeValidator personalDateRangeValidator,
PersonalAuditService personalAuditService,
ITenantPaymentRepository tenantPaymentRepository,
ITenantVisibilityRoleRuleRepository visibilityRoleRuleRepository)
: IRequestHandler<SearchPersonalPaymentsQuery, PagedResult<PersonalPaymentRecordDto>>
{
private static readonly string[] DefaultBillingVisibleRoles = ["tenant-owner", "tenant-admin"];
/// <summary>
/// 处理查询。
/// </summary>
/// <param name="request">查询请求。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>支付记录分页结果。</returns>
public async Task<PagedResult<PersonalPaymentRecordDto>> Handle(SearchPersonalPaymentsQuery request, CancellationToken cancellationToken)
{
try
{
// 1. 获取上下文并校验角色可见性
var context = personalContextService.GetRequiredContext();
var rule = await visibilityRoleRuleRepository.FindByTenantIdAsync(context.TenantId, cancellationToken);
var allowedRoles = rule?.BillingVisibleRoleCodes?.Length > 0
? rule.BillingVisibleRoleCodes
: DefaultBillingVisibleRoles;
if (!HasAnyRole(context.RoleCodes, allowedRoles))
{
throw new BusinessException(ErrorCodes.Forbidden, "当前角色无权访问支付数据");
}
// 2. 解析时间窗和分页参数
var dateRange = personalDateRangeValidator.Resolve(request.From, request.To);
var page = request.Page <= 0 ? 1 : request.Page;
var pageSize = request.PageSize <= 0 ? 20 : Math.Min(request.PageSize, 50);
// 3. 查询分页数据
var paged = await tenantPaymentRepository.SearchPagedAsync(
context.TenantId,
dateRange.From,
dateRange.To,
page,
pageSize,
cancellationToken);
var items = paged.Items.Select(MapPayment).ToList();
// 4. 记录审计并返回
await personalAuditService.RecordSensitiveQueryAsync("billing-payments", true, "查询支付记录成功", cancellationToken);
return new PagedResult<PersonalPaymentRecordDto>(items, page, pageSize, paged.Total);
}
catch (Exception ex)
{
// 5. 记录失败审计并继续抛出
await personalAuditService.RecordSensitiveQueryAsync("billing-payments", false, ex.Message, cancellationToken);
throw;
}
}
private static PersonalPaymentRecordDto MapPayment(Domain.Tenants.Entities.TenantPayment payment)
{
// 1. 映射支付方式与状态
var method = payment.Method switch
{
TenantPaymentMethod.Online => "online",
TenantPaymentMethod.BankTransfer => "bank_transfer",
TenantPaymentMethod.Other => "other",
_ => payment.Method.ToString().ToLowerInvariant()
};
var status = payment.Status switch
{
TenantPaymentStatus.Pending => "pending",
TenantPaymentStatus.Success => "success",
TenantPaymentStatus.Failed => "failed",
TenantPaymentStatus.Refunded => "refunded",
_ => payment.Status.ToString().ToLowerInvariant()
};
// 2. 返回 DTO
return new PersonalPaymentRecordDto
{
PaymentId = payment.Id,
StatementId = payment.BillingStatementId,
PaidAmount = payment.Amount,
PaymentMethod = method,
PaymentStatus = status,
PaidAt = payment.PaidAt
};
}
private static bool HasAnyRole(IReadOnlyList<string> currentRoles, IReadOnlyList<string> allowedRoles)
{
// 1. 构建当前角色集合
var roleSet = currentRoles
.Where(static x => !string.IsNullOrWhiteSpace(x))
.Select(static x => x.Trim())
.ToHashSet(StringComparer.OrdinalIgnoreCase);
// 2. 判断是否命中允许角色
return allowedRoles.Any(roleSet.Contains);
}
}

View File

@@ -0,0 +1,105 @@
using MediatR;
using TakeoutSaaS.Application.App.Personal.Dto;
using TakeoutSaaS.Application.App.Personal.Queries;
using TakeoutSaaS.Application.App.Personal.Services;
using TakeoutSaaS.Domain.Tenants.Entities;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
namespace TakeoutSaaS.Application.App.Personal.Handlers;
/// <summary>
/// 更新个人中心可见角色配置处理器。
/// </summary>
public sealed class UpdatePersonalVisibilityRoleConfigCommandHandler(
PersonalContextService personalContextService,
ITenantVisibilityRoleRuleRepository visibilityRoleRuleRepository)
: IRequestHandler<UpdatePersonalVisibilityRoleConfigCommand, PersonalVisibilityRoleConfigDto>
{
private static readonly string[] ManagerRoleCodes = ["tenant-owner", "tenant-admin"];
/// <summary>
/// 处理命令。
/// </summary>
/// <param name="request">更新命令。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>更新后的配置。</returns>
public async Task<PersonalVisibilityRoleConfigDto> Handle(UpdatePersonalVisibilityRoleConfigCommand request, CancellationToken cancellationToken)
{
// 1. 获取上下文并校验管理权限
var context = personalContextService.GetRequiredContext();
if (!HasAnyRole(context.RoleCodes, ManagerRoleCodes))
{
throw new BusinessException(ErrorCodes.Forbidden, "当前角色无权更新可见角色配置");
}
// 2. 规范化角色编码集合
var normalizedQuotaRoles = NormalizeRoleCodes(request.QuotaVisibleRoleCodes);
var normalizedBillingRoles = NormalizeRoleCodes(request.BillingVisibleRoleCodes);
// 3. 查询并落库
var existing = await visibilityRoleRuleRepository.FindByTenantIdAsync(context.TenantId, cancellationToken);
if (existing is null)
{
var created = new TenantVisibilityRoleRule
{
TenantId = context.TenantId,
QuotaVisibleRoleCodes = normalizedQuotaRoles,
BillingVisibleRoleCodes = normalizedBillingRoles,
UpdatedBy = context.UserId,
UpdatedAt = DateTime.UtcNow
};
await visibilityRoleRuleRepository.AddAsync(created, cancellationToken);
await visibilityRoleRuleRepository.SaveChangesAsync(cancellationToken);
return new PersonalVisibilityRoleConfigDto
{
TenantId = created.TenantId,
QuotaVisibleRoleCodes = created.QuotaVisibleRoleCodes,
BillingVisibleRoleCodes = created.BillingVisibleRoleCodes,
UpdatedBy = created.UpdatedBy ?? context.UserId,
UpdatedAt = created.UpdatedAt ?? DateTime.UtcNow
};
}
existing.QuotaVisibleRoleCodes = normalizedQuotaRoles;
existing.BillingVisibleRoleCodes = normalizedBillingRoles;
existing.UpdatedBy = context.UserId;
existing.UpdatedAt = DateTime.UtcNow;
await visibilityRoleRuleRepository.UpdateAsync(existing, cancellationToken);
await visibilityRoleRuleRepository.SaveChangesAsync(cancellationToken);
// 4. 返回更新结果
return new PersonalVisibilityRoleConfigDto
{
TenantId = existing.TenantId,
QuotaVisibleRoleCodes = existing.QuotaVisibleRoleCodes,
BillingVisibleRoleCodes = existing.BillingVisibleRoleCodes,
UpdatedBy = existing.UpdatedBy ?? context.UserId,
UpdatedAt = existing.UpdatedAt ?? DateTime.UtcNow
};
}
private static string[] NormalizeRoleCodes(IEnumerable<string> roleCodes)
{
// 1. 过滤空值并去重
return roleCodes
.Where(static x => !string.IsNullOrWhiteSpace(x))
.Select(static x => x.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
}
private static bool HasAnyRole(IReadOnlyList<string> currentRoles, IReadOnlyList<string> allowedRoles)
{
// 1. 构建当前角色集合
var roleSet = currentRoles
.Where(static x => !string.IsNullOrWhiteSpace(x))
.Select(static x => x.Trim())
.ToHashSet(StringComparer.OrdinalIgnoreCase);
// 2. 判断是否命中允许角色
return allowedRoles.Any(roleSet.Contains);
}
}

View File

@@ -0,0 +1,56 @@
using TakeoutSaaS.Application.App.Personal.Dto;
using TakeoutSaaS.Shared.Abstractions.Results;
namespace TakeoutSaaS.Application.App.Personal;
/// <summary>
/// 个人中心映射辅助。
/// </summary>
public static class PersonalMapping
{
/// <summary>
/// 映射分页结果。
/// </summary>
/// <typeparam name="TSource">源类型。</typeparam>
/// <typeparam name="TDestination">目标类型。</typeparam>
/// <param name="source">源分页结果。</param>
/// <param name="mapper">单项映射函数。</param>
/// <returns>目标分页结果。</returns>
public static PagedResult<TDestination> ToPagedResult<TSource, TDestination>(
this PagedResult<TSource> source,
Func<TSource, TDestination> mapper)
{
// 1. 映射分页项集合
var items = source.Items.Select(mapper).ToList();
// 2. 返回目标分页对象
return new PagedResult<TDestination>(items, source.Page, source.PageSize, source.TotalCount);
}
/// <summary>
/// 便捷构建模块状态 DTO。
/// </summary>
/// <param name="module">模块名称。</param>
/// <param name="status">模块状态。</param>
/// <param name="errorCode">错误码。</param>
/// <param name="errorMessage">错误信息。</param>
/// <param name="traceId">追踪标识。</param>
/// <returns>模块状态 DTO。</returns>
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
};
}
}

View File

@@ -0,0 +1,9 @@
using MediatR;
using TakeoutSaaS.Application.App.Personal.Dto;
namespace TakeoutSaaS.Application.App.Personal.Queries;
/// <summary>
/// 获取个人中心总览查询。
/// </summary>
public sealed record GetPersonalOverviewQuery : IRequest<PersonalOverviewDto>;

View File

@@ -0,0 +1,9 @@
using MediatR;
using TakeoutSaaS.Application.App.Personal.Dto;
namespace TakeoutSaaS.Application.App.Personal.Queries;
/// <summary>
/// 获取个人中心配额摘要查询。
/// </summary>
public sealed record GetPersonalQuotaQuery : IRequest<PersonalQuotaUsageSummaryDto>;

View File

@@ -0,0 +1,9 @@
using MediatR;
using TakeoutSaaS.Application.App.Personal.Dto;
namespace TakeoutSaaS.Application.App.Personal.Queries;
/// <summary>
/// 获取个人角色权限概览查询。
/// </summary>
public sealed record GetPersonalRolesQuery : IRequest<PersonalRolePermissionSummaryDto>;

View File

@@ -0,0 +1,9 @@
using MediatR;
using TakeoutSaaS.Application.App.Personal.Dto;
namespace TakeoutSaaS.Application.App.Personal.Queries;
/// <summary>
/// 获取个人中心可见角色配置查询。
/// </summary>
public sealed record GetPersonalVisibilityRoleConfigQuery : IRequest<PersonalVisibilityRoleConfigDto>;

View File

@@ -0,0 +1,31 @@
using MediatR;
using TakeoutSaaS.Application.App.Personal.Dto;
using TakeoutSaaS.Shared.Abstractions.Results;
namespace TakeoutSaaS.Application.App.Personal.Queries;
/// <summary>
/// 分页查询个人中心账单记录。
/// </summary>
public sealed record SearchPersonalBillingStatementsQuery : IRequest<PagedResult<PersonalBillingStatementDto>>
{
/// <summary>
/// 页码。
/// </summary>
public int Page { get; init; } = 1;
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; init; } = 20;
/// <summary>
/// 开始时间UTC
/// </summary>
public DateTime? From { get; init; }
/// <summary>
/// 结束时间UTC
/// </summary>
public DateTime? To { get; init; }
}

View File

@@ -0,0 +1,26 @@
using MediatR;
using TakeoutSaaS.Application.App.Personal.Dto;
using TakeoutSaaS.Shared.Abstractions.Results;
namespace TakeoutSaaS.Application.App.Personal.Queries;
/// <summary>
/// 分页查询个人消息摘要。
/// </summary>
public sealed record SearchPersonalNotificationsQuery : IRequest<PagedResult<PersonalNotificationDto>>
{
/// <summary>
/// 页码。
/// </summary>
public int Page { get; init; } = 1;
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; init; } = 20;
/// <summary>
/// 是否仅返回未读。
/// </summary>
public bool UnreadOnly { get; init; }
}

View File

@@ -0,0 +1,31 @@
using MediatR;
using TakeoutSaaS.Application.App.Personal.Dto;
using TakeoutSaaS.Shared.Abstractions.Results;
namespace TakeoutSaaS.Application.App.Personal.Queries;
/// <summary>
/// 分页查询个人操作记录。
/// </summary>
public sealed record SearchPersonalOperationsQuery : IRequest<PagedResult<PersonalOperationLogDto>>
{
/// <summary>
/// 页码。
/// </summary>
public int Page { get; init; } = 1;
/// <summary>
/// 每页条数(默认 50上限 50
/// </summary>
public int PageSize { get; init; } = 50;
/// <summary>
/// 开始时间UTC
/// </summary>
public DateTime? From { get; init; }
/// <summary>
/// 结束时间UTC
/// </summary>
public DateTime? To { get; init; }
}

View File

@@ -0,0 +1,31 @@
using MediatR;
using TakeoutSaaS.Application.App.Personal.Dto;
using TakeoutSaaS.Shared.Abstractions.Results;
namespace TakeoutSaaS.Application.App.Personal.Queries;
/// <summary>
/// 分页查询个人中心支付记录。
/// </summary>
public sealed record SearchPersonalPaymentsQuery : IRequest<PagedResult<PersonalPaymentRecordDto>>
{
/// <summary>
/// 页码。
/// </summary>
public int Page { get; init; } = 1;
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; init; } = 20;
/// <summary>
/// 开始时间UTC
/// </summary>
public DateTime? From { get; init; }
/// <summary>
/// 结束时间UTC
/// </summary>
public DateTime? To { get; init; }
}

View File

@@ -0,0 +1,20 @@
using MediatR;
using TakeoutSaaS.Application.App.Personal.Dto;
namespace TakeoutSaaS.Application.App.Personal.Queries;
/// <summary>
/// 更新个人中心可见角色配置命令。
/// </summary>
public sealed record UpdatePersonalVisibilityRoleConfigCommand : IRequest<PersonalVisibilityRoleConfigDto>
{
/// <summary>
/// 配额可见角色编码集合。
/// </summary>
public string[] QuotaVisibleRoleCodes { get; init; } = [];
/// <summary>
/// 账单可见角色编码集合。
/// </summary>
public string[] BillingVisibleRoleCodes { get; init; } = [];
}

View File

@@ -0,0 +1,38 @@
using Microsoft.Extensions.Logging;
namespace TakeoutSaaS.Application.App.Personal.Services;
/// <summary>
/// 个人中心敏感查询审计服务。
/// </summary>
public sealed class PersonalAuditService(
PersonalContextService personalContextService,
ILogger<PersonalAuditService> logger)
{
/// <summary>
/// 记录敏感查询审计日志。
/// </summary>
/// <param name="module">模块名称。</param>
/// <param name="isSuccess">是否成功。</param>
/// <param name="detail">审计说明。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>异步任务。</returns>
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;
}
}

View File

@@ -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;
/// <summary>
/// 个人中心请求上下文服务。
/// </summary>
public sealed class PersonalContextService(
ICurrentUserAccessor currentUserAccessor,
ITenantProvider tenantProvider,
IHttpContextAccessor httpContextAccessor)
{
/// <summary>
/// 获取当前请求必需上下文。
/// </summary>
/// <returns>用户 ID、租户 ID、角色编码集合、追踪标识。</returns>
public (long UserId, long TenantId, IReadOnlyList<string> 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);
}
/// <summary>
/// 获取当前请求 TraceId。
/// </summary>
/// <returns>TraceId若不存在则返回空字符串。</returns>
public string ResolveTraceId()
{
// 1. 从 Activity 与 HttpContext 中解析 TraceId
var traceId = System.Diagnostics.Activity.Current?.TraceId.ToString()
?? httpContextAccessor.HttpContext?.TraceIdentifier
?? string.Empty;
// 2. 返回追踪标识
return traceId;
}
/// <summary>
/// 获取当前用户角色编码。
/// </summary>
/// <returns>角色编码集合。</returns>
public IReadOnlyList<string> 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;
}
}

View File

@@ -0,0 +1,61 @@
namespace TakeoutSaaS.Application.App.Personal.Services;
/// <summary>
/// 个人中心敏感信息脱敏服务。
/// </summary>
public sealed class PersonalMaskingService
{
/// <summary>
/// 手机号脱敏。
/// </summary>
/// <param name="phone">原始手机号。</param>
/// <returns>脱敏手机号。</returns>
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..]}";
}
/// <summary>
/// 邮箱脱敏。
/// </summary>
/// <param name="email">原始邮箱。</param>
/// <returns>脱敏邮箱。</returns>
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}";
}
}

View File

@@ -0,0 +1,101 @@
using TakeoutSaaS.Application.App.Personal.Dto;
namespace TakeoutSaaS.Application.App.Personal.Services;
/// <summary>
/// 个人中心模块状态构建服务。
/// </summary>
public sealed class PersonalModuleStatusService
{
/// <summary>
/// 构建成功状态。
/// </summary>
/// <param name="module">模块名称。</param>
/// <param name="traceId">追踪标识。</param>
/// <returns>模块状态 DTO。</returns>
public PersonalModuleStatusDto BuildOk(string module, string? traceId = null)
{
// 1. 生成成功状态
return new PersonalModuleStatusDto
{
Module = module,
Status = "ok",
TraceId = traceId
};
}
/// <summary>
/// 构建失败或降级状态。
/// </summary>
/// <param name="module">模块名称。</param>
/// <param name="status">状态值degraded/failed/timeout/skipped。</param>
/// <param name="errorCode">错误码。</param>
/// <param name="errorMessage">错误说明。</param>
/// <param name="traceId">追踪标识。</param>
/// <returns>模块状态 DTO。</returns>
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
};
}
/// <summary>
/// 计算总览整体状态。
/// </summary>
/// <param name="moduleStatuses">模块状态集合。</param>
/// <param name="hasAnyData">是否存在可用数据。</param>
/// <returns>overallStatus 值。</returns>
public string ResolveOverallStatus(IReadOnlyCollection<PersonalModuleStatusDto> 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"
};
}
}

View File

@@ -0,0 +1,18 @@
using FluentValidation;
using TakeoutSaaS.Application.App.Personal.Queries;
namespace TakeoutSaaS.Application.App.Personal.Validators;
/// <summary>
/// 个人中心总览查询校验器。
/// </summary>
public sealed class GetPersonalOverviewQueryValidator : AbstractValidator<GetPersonalOverviewQuery>
{
/// <summary>
/// 初始化校验规则。
/// </summary>
public GetPersonalOverviewQueryValidator()
{
// 1. 当前查询无显式参数,保留验证器用于后续扩展
}
}

View File

@@ -0,0 +1,43 @@
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
namespace TakeoutSaaS.Application.App.Personal.Validators;
/// <summary>
/// 个人中心通用时间范围校验器。
/// </summary>
public sealed class PersonalDateRangeValidator
{
private const int DefaultDays = 90;
private const int MaxRangeDays = 365;
/// <summary>
/// 解析并校验时间范围。
/// </summary>
/// <param name="from">起始时间。</param>
/// <param name="to">截止时间。</param>
/// <returns>校验后的起止时间UTC。</returns>
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);
}
}

View File

@@ -0,0 +1,32 @@
using FluentValidation;
using TakeoutSaaS.Application.App.Personal.Queries;
namespace TakeoutSaaS.Application.App.Personal.Validators;
/// <summary>
/// 更新个人中心可见角色配置命令校验器。
/// </summary>
public sealed class UpdatePersonalVisibilityRoleConfigCommandValidator : AbstractValidator<UpdatePersonalVisibilityRoleConfigCommand>
{
/// <summary>
/// 初始化校验规则。
/// </summary>
public UpdatePersonalVisibilityRoleConfigCommandValidator()
{
RuleFor(x => x.QuotaVisibleRoleCodes)
.NotEmpty()
.Must(HasNoBlankValue)
.WithMessage("配额可见角色列表不能为空且不能包含空白角色编码");
RuleFor(x => x.BillingVisibleRoleCodes)
.NotEmpty()
.Must(HasNoBlankValue)
.WithMessage("账单可见角色列表不能为空且不能包含空白角色编码");
}
private static bool HasNoBlankValue(IEnumerable<string> roleCodes)
{
// 1. 校验角色编码列表不包含空值
return roleCodes.All(static x => !string.IsNullOrWhiteSpace(x));
}
}

View File

@@ -0,0 +1,20 @@
using TakeoutSaaS.Shared.Abstractions.Entities;
namespace TakeoutSaaS.Domain.Tenants.Entities;
/// <summary>
/// 租户账单/配额可见角色规则。
/// </summary>
public sealed class TenantVisibilityRoleRule : MultiTenantEntityBase
{
/// <summary>
/// 配额可见角色编码集合。
/// </summary>
public string[] QuotaVisibleRoleCodes { get; set; } = [];
/// <summary>
/// 账单可见角色编码集合。
/// </summary>
public string[] BillingVisibleRoleCodes { get; set; } = [];
}

View File

@@ -7,6 +7,26 @@ namespace TakeoutSaaS.Domain.Tenants.Repositories;
/// </summary>
public interface IOperationLogRepository
{
/// <summary>
/// 分页查询指定操作人的操作日志。
/// </summary>
/// <param name="tenantId">租户 ID。</param>
/// <param name="operatorId">操作人标识。</param>
/// <param name="from">开始时间UTC。</param>
/// <param name="to">结束时间UTC。</param>
/// <param name="page">页码(从 1 开始)。</param>
/// <param name="pageSize">每页条数。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>分页结果(数据与总数)。</returns>
Task<(IReadOnlyList<OperationLog> Items, int Total)> SearchByOperatorPagedAsync(
long tenantId,
string operatorId,
DateTime from,
DateTime to,
int page,
int pageSize,
CancellationToken cancellationToken = default);
/// <summary>
/// 新增操作日志。
/// </summary>

View File

@@ -7,6 +7,24 @@ namespace TakeoutSaaS.Domain.Tenants.Repositories;
/// </summary>
public interface ITenantPaymentRepository
{
/// <summary>
/// 分页查询支付记录。
/// </summary>
/// <param name="tenantId">租户 ID。</param>
/// <param name="from">开始时间UTC。</param>
/// <param name="to">结束时间UTC。</param>
/// <param name="page">页码(从 1 开始)。</param>
/// <param name="pageSize">每页条数。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>分页结果(数据与总数)。</returns>
Task<(IReadOnlyList<TenantPayment> Items, int Total)> SearchPagedAsync(
long tenantId,
DateTime from,
DateTime to,
int page,
int pageSize,
CancellationToken cancellationToken = default);
/// <summary>
/// 查询指定账单的支付记录列表。
/// </summary>

View File

@@ -0,0 +1,40 @@
using TakeoutSaaS.Domain.Tenants.Entities;
namespace TakeoutSaaS.Domain.Tenants.Repositories;
/// <summary>
/// 租户可见角色规则仓储。
/// </summary>
public interface ITenantVisibilityRoleRuleRepository
{
/// <summary>
/// 按租户获取规则。
/// </summary>
/// <param name="tenantId">租户 ID。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>规则实体,未配置时返回 null。</returns>
Task<TenantVisibilityRoleRule?> FindByTenantIdAsync(long tenantId, CancellationToken cancellationToken = default);
/// <summary>
/// 新增规则。
/// </summary>
/// <param name="rule">规则实体。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>异步任务。</returns>
Task AddAsync(TenantVisibilityRoleRule rule, CancellationToken cancellationToken = default);
/// <summary>
/// 更新规则。
/// </summary>
/// <param name="rule">规则实体。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>异步任务。</returns>
Task UpdateAsync(TenantVisibilityRoleRule rule, CancellationToken cancellationToken = default);
/// <summary>
/// 保存变更。
/// </summary>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>异步任务。</returns>
Task SaveChangesAsync(CancellationToken cancellationToken = default);
}

View File

@@ -56,6 +56,7 @@ public static class AppServiceCollectionExtensions
services.AddScoped<ITenantPackageRepository, EfTenantPackageRepository>();
services.AddScoped<ITenantQuotaUsageRepository, EfTenantQuotaUsageRepository>();
services.AddScoped<ITenantQuotaUsageHistoryRepository, EfTenantQuotaUsageHistoryRepository>();
services.AddScoped<ITenantVisibilityRoleRuleRepository, TenantVisibilityRoleRuleRepository>();
services.AddScoped<IInventoryRepository, EfInventoryRepository>();
services.AddScoped<IQuotaPackageRepository, EfQuotaPackageRepository>();
services.AddScoped<IStatisticsRepository, EfStatisticsRepository>();

View File

@@ -11,6 +11,39 @@ namespace TakeoutSaaS.Infrastructure.App.Persistence.Repositories;
/// </summary>
public sealed class TenantPaymentRepository(TakeoutAppDbContext context) : ITenantPaymentRepository
{
/// <inheritdoc />
public async Task<(IReadOnlyList<TenantPayment> 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);
}
/// <inheritdoc />
public async Task<IReadOnlyList<TenantPayment>> GetByBillingIdAsync(long billingStatementId, CancellationToken cancellationToken = default)
{

View File

@@ -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;
/// <summary>
/// 租户可见角色规则仓储实现。
/// </summary>
public sealed class TenantVisibilityRoleRuleRepository(TakeoutAppDbContext context) : ITenantVisibilityRoleRuleRepository
{
/// <summary>
/// 按租户获取规则。
/// </summary>
/// <param name="tenantId">租户 ID。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>规则实体或 null。</returns>
public Task<TenantVisibilityRoleRule?> FindByTenantIdAsync(long tenantId, CancellationToken cancellationToken = default)
{
return context.TenantVisibilityRoleRules
.AsNoTracking()
.FirstOrDefaultAsync(x => x.TenantId == tenantId && x.DeletedAt == null, cancellationToken);
}
/// <summary>
/// 新增规则。
/// </summary>
/// <param name="rule">规则实体。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>异步任务。</returns>
public Task AddAsync(TenantVisibilityRoleRule rule, CancellationToken cancellationToken = default)
{
return context.TenantVisibilityRoleRules.AddAsync(rule, cancellationToken).AsTask();
}
/// <summary>
/// 更新规则。
/// </summary>
/// <param name="rule">规则实体。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>异步任务。</returns>
public Task UpdateAsync(TenantVisibilityRoleRule rule, CancellationToken cancellationToken = default)
{
context.TenantVisibilityRoleRules.Update(rule);
return Task.CompletedTask;
}
/// <summary>
/// 保存变更。
/// </summary>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>异步任务。</returns>
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
{
return context.SaveChangesAsync(cancellationToken);
}
}

View File

@@ -88,6 +88,10 @@ public sealed class TakeoutAppDbContext(
/// </summary>
public DbSet<TenantVerificationProfile> TenantVerificationProfiles => Set<TenantVerificationProfile>();
/// <summary>
/// 租户账单/配额可见角色规则。
/// </summary>
public DbSet<TenantVisibilityRoleRule> TenantVisibilityRoleRules => Set<TenantVisibilityRoleRule>();
/// <summary>
/// 配额包定义。
/// </summary>
public DbSet<QuotaPackage> QuotaPackages => Set<QuotaPackage>();
@@ -394,6 +398,7 @@ public sealed class TakeoutAppDbContext(
ConfigureTenantAnnouncement(modelBuilder.Entity<TenantAnnouncement>());
ConfigureTenantAnnouncementRead(modelBuilder.Entity<TenantAnnouncementRead>());
ConfigureTenantVerificationProfile(modelBuilder.Entity<TenantVerificationProfile>());
ConfigureTenantVisibilityRoleRule(modelBuilder.Entity<TenantVisibilityRoleRule>());
ConfigureQuotaPackage(modelBuilder.Entity<QuotaPackage>());
ConfigureTenantQuotaPackagePurchase(modelBuilder.Entity<TenantQuotaPackagePurchase>());
ConfigureMerchantDocument(modelBuilder.Entity<MerchantDocument>());
@@ -834,6 +839,18 @@ public sealed class TakeoutAppDbContext(
builder.HasIndex(x => new { x.TenantId, x.Channel, x.SentAt });
}
private static void ConfigureTenantVisibilityRoleRule(EntityTypeBuilder<TenantVisibilityRoleRule> 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<TenantAnnouncement> builder)
{
builder.ToTable("tenant_announcements");

View File

@@ -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;
/// </summary>
public sealed class EfOperationLogRepository(TakeoutLogsDbContext logsContext) : IOperationLogRepository
{
/// <inheritdoc />
public async Task<(IReadOnlyList<OperationLog> 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);
}
/// <inheritdoc />
public Task AddAsync(OperationLog log, CancellationToken cancellationToken = default)
{